├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── criteria.js ├── cursor.js ├── db.js ├── geo.js ├── id.js ├── index.js ├── modifier.js ├── special.js ├── table.js ├── unique.js └── utils.js ├── package.json └── test ├── criteria.js ├── db.js ├── geo.js ├── id.js ├── modifier.js ├── table.js ├── unique.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | 11 | **/.vs 12 | **/.vscode 13 | **/.idea 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | !.npmignore 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "8" 5 | - "10" 6 | - "11" 7 | - "node" 8 | 9 | addons: 10 | rethinkdb: "2.3" 11 | 12 | sudo: false 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2019, Eran Hammer and Project contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * The names of any contributors may not be used to endorse or promote 12 | products derived from this software without specific prior written 13 | permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # penseur 2 | 3 | Application-friendly RethinkDB client 4 | 5 | ## API 6 | 7 | ### `new Penseur.Db(name, [options])` 8 | 9 | Creates a new `Db` object where: 10 | - `name` - name of the database to use. Default is `'test'`. 11 | - `options` - optional configuration: 12 | - `host` - string containing the host to connect to. Default is `'localhost'` 13 | - `port` - port to connect on. Default is `28015`. 14 | - `authKey` - string containing an established authorization key 15 | - `user` - string containing the user you want to perform actions as. Default is `'admin'` 16 | - `password` - string containing the users password 17 | - `timeout` - number indicating the number of seconds to wait for a connection to be established before timing out. Default is `20` seconds. 18 | - `ssl` - ssl settings when RethinkDB requires a secure connection. This setting is passed to the RethinkDB module. 19 | - `ca` - certificate as a Buffer used for establishing a secure connection to rethink, see [RethinkDB connect documentation](https://www.rethinkdb.com/api/javascript/connect/) 20 | - `test` - boolean indicating if the `Db` actions should take place. Useful for testing scenarios, do not use in production. 21 | - `extended` - object that is used to extend the members of the table objects. 22 | - `onConnect` - function to execute when a connection is established. 23 | - `onError` - function to execute when an error occurs. Signature is `function(err)` where `err` is an error object. 24 | - `onDisconnect`- function to execute when a disconnection occurs. Signature is `function(willReconnect)` where `willReconnect` is a boolean indicating if the connection will be reestablished. 25 | - `reconnect` - boolean indicating if the connection should be reestablished when interrupted 26 | - `reconnectTimeout` - number of milliseconds to wait between reconnect attempts, when `false` there is no timeout. Defaults to `100` 27 | 28 | 29 | #### `await db.connect()` 30 | 31 | Create a connection to the database. Throws connection errors. 32 | 33 | 34 | #### `await db.close()` 35 | 36 | Close all database connections. 37 | 38 | 39 | #### `await db.establish([tables])` 40 | 41 | Note that this can alter data and indexes, not intended of production use. 42 | 43 | Establish a connection if one doesn't exist and create the database and tables if they don't already exist. This function also decorates the `db` object with helper properties to for using each of the tables. 44 | 45 | - `[tables]` - array of strings with the name of each table to create 46 | 47 | 48 | #### `db.disable(table, method, [options])` 49 | 50 | Only available when `Db` is constructed in test mode. Disable a specific method from being performed on a table, used for testing. 51 | 52 | - `table` - name of table 53 | - `method` - name of method on table to disable 54 | - `options` - optional object with the following properties 55 | - `value` - value to return when the method is called. Can be an error object to simulate errors. 56 | 57 | 58 | #### `db.enable(table, method)` 59 | 60 | Only available when `Db` is constructed in test mode. Enable a disabled method on a table, used for testing. 61 | 62 | - `table` - name of table 63 | - `method` - name of method on table to enable 64 | 65 | 66 | #### `db.r` 67 | 68 | Property that contains the [RethinkDB module](https://www.npmjs.com/package/rethinkdb). 69 | 70 | 71 | ### Table functions 72 | 73 | After a database connection exists and tables are established then the `Db` object is decorated with properties for each table name containing various helper function. Below are the available functions for each table. 74 | 75 | #### `await db[table].get(id, [options])` 76 | 77 | Retrieve a record in the `table` with the given `id`. `id` itself can be an array of `id` values if you want to retrieve multiple records. 78 | 79 | - `id` - unique identifier of record to retrieve. Can be an array with values for each ID to retrieve. 80 | - `options` - optional object with the following properties 81 | - `sort` - table key to sort results using 82 | - `order` - `'descending'` or `'ascending'` for sort order, Defaults to `'ascending'` 83 | - `from` - index in result set to select 84 | - `count` - number of records to return in results 85 | - `filter` - properties to pluck from the results 86 | 87 | 88 | #### `await db[table].all()` 89 | 90 | Retrieve all records for a table. 91 | 92 | 93 | #### `await db[table].exist(id)` 94 | 95 | Determine if a record in the `table` exists with the provided ID 96 | 97 | - `id` - unique identifier of record to retrieve. Can be an array with values for each ID to retrieve. 98 | 99 | 100 | #### `await db[table].query(criteria)` 101 | 102 | Perform a query on the table using the provided criteria. Criteria is available on the `Db` object and is listed in the criteria section below. 103 | 104 | - `criteria` - db [criteria](#criteria) functions chained together 105 | 106 | 107 | #### `await db[table].single(criteria)` 108 | 109 | Retrieve a single record from the provided criteria. 110 | 111 | - `criteria` - db [criteria](#criteria) functions chained together 112 | 113 | 114 | #### `await db[table].count(criteria)` 115 | 116 | Retrieve the number of records in the table that match the given criteria. 117 | 118 | - `criteria` - db [criteria](#criteria) functions chained together 119 | 120 | 121 | #### `await db[table].insert(items, [options])` 122 | 123 | Create new record(s) in the table. Each item can specify a unique `id` property or allow rethink to generate one for them. 124 | 125 | - `items` - item object or array of items to insert into the table 126 | - `options` - optional object with the following properties 127 | - `merge` - boolean, when true any conflicts with existing items will result in an update, when false an error is returned 128 | - `chunks` - maximum number of updates to send to the database at the same time 129 | 130 | 131 | #### `await db[table].update(ids, changes)` 132 | 133 | Update an existing record with the provided changes. 134 | 135 | - `ids` - an identifier or array of identifiers of records to update in the table 136 | - `changes` - the parts of the record to change and the values to change the parts to 137 | 138 | 139 | #### `await db[table].update(updates, [options])` 140 | 141 | Update an existing record with the provided changes. 142 | 143 | - `updates` - an array of records to update (each must include an existing primary key) 144 | - `options` - optional settings where: 145 | - `chunks` - maximum number of updates to send to the database at the same time 146 | 147 | 148 | #### `await db[table].remove(criteria)` 149 | 150 | Remove the records in the table that match the given criteria. 151 | 152 | - `criteria` - db [criteria](#criteria) functions chained together 153 | 154 | 155 | #### `await db[table].empty()` 156 | 157 | Remove all records in the table. 158 | 159 | 160 | #### `await db[table].sync()` 161 | 162 | Wait until all operations are complete and all data is persisted on permanent storage. Note that this function shouldn't be necessary for normal conditions. 163 | 164 | 165 | #### `await db[table].index(indexes)` 166 | 167 | Create the secondary `indexes` on the table. 168 | 169 | - `indexes` - a string or array of strings for each index to create 170 | 171 | 172 | #### `await db[table].changes(criteria, [options])` 173 | 174 | Subscribe to changes matching the given criteria for the table. 175 | 176 | - `criteria` - db [criteria](#criteria) functions chained together 177 | - `options` - optional object with the following properties 178 | - `handler` - handler function to execute when changes occur. 179 | - `reconnect` - boolean, reconnect if the connection to the feed is interrupted 180 | - `initial` - boolean, include the initial results in the change feed 181 | 182 | 183 | ### Criteria 184 | 185 | #### `Db.or(values)` 186 | #### `Db.contains(values[, options])` 187 | #### `Db.not(values)` 188 | #### `Db.unset()` 189 | #### `Db.empty()` 190 | #### `Db.increment(value)` 191 | #### `Db.append(value[, options])` 192 | #### `Db.override(value)` 193 | #### `Db.is(operator, values, ...and)` 194 | #### `Db.by(index, values)` 195 | -------------------------------------------------------------------------------- /lib/criteria.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('hoek'); 4 | const RethinkDB = require('rethinkdb'); 5 | 6 | const Geo = require('./geo'); 7 | const Special = require('./special'); 8 | 9 | 10 | const internals = {}; 11 | 12 | 13 | exports.select = function (criteria, table, options = {}) { 14 | 15 | if (criteria === null) { 16 | return table.raw; 17 | } 18 | 19 | // Optimize secondary index query 20 | 21 | if (Special.isSpecial(criteria) && 22 | criteria.type === 'by') { 23 | 24 | return table.raw.getAll(RethinkDB.args(criteria.value), { index: criteria.flags.index }); 25 | } 26 | 27 | // Construct query 28 | 29 | const base = (table._geo ? Geo.select(criteria, table) : table.raw); 30 | const filter = internals.compile(criteria, null, options); 31 | return (filter ? base.filter(filter) : base); 32 | }; 33 | 34 | 35 | internals.compile = function (criteria, relative, options) { 36 | 37 | /* 38 | const criteria = { 39 | a: { 40 | b: 1 41 | }, 42 | c: 2 43 | }; 44 | 45 | const filter = Rethinkdb.and(Rethinkdb.row('a')('b').eq(1), Rethinkdb.row('c').eq(2)); 46 | */ 47 | 48 | const edges = []; 49 | const lines = internals.flatten(criteria, relative || [], edges); 50 | if (!lines.length) { 51 | return criteria; 52 | } 53 | 54 | const tests = []; 55 | for (let i = 0; i < lines.length; ++i) { 56 | const path = lines[i].path; 57 | let row = exports.row(path); 58 | const value = lines[i].value; 59 | 60 | // Simple value 61 | 62 | if (!Special.isSpecial(value)) { 63 | const condition = (options.match ? row.match(internals.match(value, options.match)) : row.eq(value)); 64 | tests.push(condition.default(null)); 65 | continue; 66 | } 67 | 68 | // Special rule 69 | 70 | if (value.type === 'near') { 71 | continue; 72 | } 73 | 74 | Hoek.assert(['contains', 'is', 'not', 'or', 'unset', 'empty', 'match'].indexOf(value.type) !== -1, `Unknown criteria value type ${value.type}`); 75 | 76 | if (value.type === 'contains') { 77 | 78 | // Contains 79 | 80 | if (value.flags.keys || 81 | !path) { 82 | 83 | row = row.keys(); 84 | } 85 | 86 | if (!Array.isArray(value.value)) { 87 | tests.push(row.contains(value.value)); 88 | } 89 | else { 90 | const conditions = []; 91 | for (let j = 0; j < value.value.length; ++j) { 92 | conditions.push(row.contains(value.value[j])); 93 | } 94 | 95 | tests.push(RethinkDB[value.flags.condition || 'and'](...conditions)); 96 | } 97 | } 98 | else if (value.type === 'match') { 99 | 100 | // Match 101 | 102 | if (!Array.isArray(value.value)) { 103 | tests.push(row.match(internals.match(value.value, value.flags))); 104 | } 105 | else { 106 | const conditions = []; 107 | for (let j = 0; j < value.value.length; ++j) { 108 | conditions.push(row.match(internals.match(value.value[j], value.flags))); 109 | } 110 | 111 | tests.push(RethinkDB[value.flags.condition || 'and'](...conditions)); 112 | } 113 | } 114 | else if (value.type === 'or') { 115 | 116 | // Or 117 | 118 | const ors = []; 119 | for (let j = 0; j < value.value.length; ++j) { 120 | const orValue = value.value[j]; 121 | if (Special.isSpecial(orValue)) { 122 | Hoek.assert(['unset', 'is', 'empty'].indexOf(orValue.type) !== -1, `Unknown or criteria value type ${orValue.type}`); 123 | 124 | if (orValue.type === 'unset') { 125 | ors.push(exports.row(path.slice(0, -1)).hasFields(path[path.length - 1]).not()); 126 | } 127 | else if (orValue.type === 'empty') { 128 | ors.push(internals.empty(path)); 129 | } 130 | else { 131 | ors.push(internals.toComparator(row, orValue)); 132 | } 133 | } 134 | else if (typeof orValue === 'object') { 135 | ors.push(internals.compile(orValue, path, options)); 136 | } 137 | else { 138 | ors.push(row.eq(orValue).default(null)); 139 | } 140 | } 141 | 142 | let test = RethinkDB.or(...ors); 143 | if (value.flags.not) { 144 | test = test.not(); 145 | } 146 | 147 | tests.push(test); 148 | } 149 | else if (value.type === 'is') { 150 | 151 | // Is 152 | 153 | tests.push(internals.toComparator(row, value)); 154 | } 155 | else if (value.type === 'empty') { 156 | 157 | // empty 158 | 159 | tests.push(internals.empty(path)); 160 | } 161 | else { 162 | 163 | // Unset 164 | 165 | tests.push(exports.row(path.slice(0, -1)).hasFields(path[path.length - 1]).not()); 166 | } 167 | } 168 | 169 | if (!tests.length) { 170 | return null; 171 | } 172 | 173 | criteria = (tests.length === 1 ? tests[0] : RethinkDB.and(...tests)); 174 | 175 | if (edges.length) { 176 | let typeCheck = exports.row(edges[0]).typeOf().eq('OBJECT'); 177 | 178 | for (let i = 1; i < edges.length; ++i) { 179 | typeCheck = RethinkDB.and(typeCheck, exports.row(edges[i]).typeOf().eq('OBJECT')); 180 | } 181 | 182 | criteria = typeCheck.and(criteria); 183 | } 184 | 185 | return criteria; 186 | }; 187 | 188 | 189 | internals.flatten = function (criteria, path, edges) { 190 | 191 | if (Special.isSpecial(criteria)) { 192 | return [{ value: criteria }]; 193 | } 194 | 195 | const keys = Object.keys(criteria); 196 | let lines = []; 197 | for (let i = 0; i < keys.length; ++i) { 198 | const key = keys[i]; 199 | const value = criteria[key]; 200 | const location = path.concat(key); 201 | 202 | if (typeof value === 'object' && 203 | !Special.isSpecial(value)) { 204 | 205 | edges.push(location); 206 | lines = lines.concat(internals.flatten(value, location, edges)); 207 | } 208 | else { 209 | lines.push({ path: location, value }); 210 | } 211 | } 212 | 213 | return lines; 214 | }; 215 | 216 | 217 | exports.row = function (path) { 218 | 219 | if (!path) { 220 | return RethinkDB.row; 221 | } 222 | 223 | path = [].concat(path); 224 | if (!path.length) { 225 | return RethinkDB.row; 226 | } 227 | 228 | let row = RethinkDB.row(path[0]); 229 | for (let i = 1; i < path.length; ++i) { 230 | row = row(path[i]); 231 | } 232 | 233 | return row; 234 | }; 235 | 236 | 237 | internals.toComparator = function (row, value) { 238 | 239 | const primary = row[value.flags.comparator](value.value).default(null); 240 | 241 | if (!value.flags.and) { 242 | return primary; 243 | } 244 | 245 | const ands = [primary]; 246 | value.flags.and.forEach((condition) => ands.push(row[condition.comparator](condition.value).default(null))); 247 | return RethinkDB.and(...ands); 248 | }; 249 | 250 | 251 | internals.match = function (value, options) { 252 | 253 | let prefix = ''; 254 | let suffix = ''; 255 | 256 | if (options.insensitive) { 257 | prefix = '(?i)'; 258 | } 259 | 260 | if (options.start || 261 | options.exact) { 262 | 263 | prefix += '^'; 264 | } 265 | 266 | if (options.end || 267 | options.exact) { 268 | 269 | suffix = '$'; 270 | } 271 | 272 | return prefix + value + suffix; 273 | }; 274 | 275 | 276 | internals.empty = function (path) { 277 | 278 | const selector = path.reduce((memo, next) => memo(next), exports.row); 279 | return RethinkDB.branch( 280 | selector.typeOf().eq('ARRAY'), selector.isEmpty(), 281 | selector.typeOf().eq('OBJECT'), selector.keys().isEmpty(), 282 | false); 283 | }; 284 | -------------------------------------------------------------------------------- /lib/cursor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const internals = {}; 4 | 5 | 6 | exports = module.exports = class { 7 | 8 | constructor(cursor, table, feedId) { 9 | 10 | this._cursor = cursor; 11 | this._table = table; 12 | this._feedId = feedId; 13 | 14 | this._table._cursors.push(cursor); 15 | } 16 | 17 | close(_cleanup) { 18 | 19 | if (_cleanup !== false) { // Defaults to true 20 | delete this._table._db._feeds[this._feedId]; 21 | } 22 | 23 | this._table._cursors = this._table._cursors.filter((cursor) => cursor !== this._cursor); 24 | this._cursor.close(); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('boom'); 4 | const Bounce = require('bounce'); 5 | const Hoek = require('hoek'); 6 | const Joi = require('joi'); 7 | const RethinkDB = require('rethinkdb'); 8 | 9 | const Id = require('./id'); 10 | const Special = require('./special'); 11 | const Table = require('./table'); 12 | const Unique = require('./unique'); 13 | 14 | 15 | const internals = { 16 | comparators: { 17 | '<': 'lt', 18 | '>': 'gt', 19 | '=': 'eq', 20 | '==': 'eq', 21 | '<=': 'le', 22 | '>=': 'ge', 23 | '!=': 'ne', 24 | lt: 'lt', 25 | gt: 'gt', 26 | eq: 'eq', 27 | ne: 'ne', 28 | le: 'le', 29 | ge: 'ge' 30 | } 31 | }; 32 | 33 | 34 | internals.unique = Joi.object({ 35 | path: Joi.array().items(Joi.string()).min(1).single().required(), 36 | table: Joi.string(), // Defaults to penseur_unique_{table}_{unique_path_path} 37 | key: Joi.string().default('holder') 38 | }); 39 | 40 | 41 | internals.secondaryIndex = Joi.object({ 42 | name: Joi.string().required(), 43 | source: Joi.alternatives([ 44 | Joi.array().items(Joi.string()).min(2), // List of row fields 45 | Joi.func() // A function (row) => { } 46 | ]), 47 | options: Joi.object({ 48 | multi: Joi.boolean().default(false), 49 | geo: Joi.boolean().default(false) 50 | }) 51 | .default({}) 52 | }); 53 | 54 | 55 | internals.schema = { 56 | db: Joi.object({ 57 | 58 | // Rethink Connection 59 | 60 | host: Joi.string(), 61 | port: Joi.number(), 62 | authKey: Joi.string(), 63 | user: Joi.string(), 64 | password: Joi.string(), 65 | timeout: Joi.number(), 66 | ssl: { 67 | ca: Joi.any() 68 | }, 69 | 70 | // Penseur options 71 | 72 | test: [Joi.boolean(), Joi.object()], 73 | extended: Joi.any(), 74 | onConnect: Joi.func().default(Hoek.ignore), 75 | onError: Joi.func().default(Hoek.ignore), 76 | onDisconnect: Joi.func().default(Hoek.ignore), 77 | reconnect: Joi.boolean().default(true), 78 | reconnectTimeout: Joi.number().integer().min(1).allow(false).default(100) 79 | }), 80 | table: Joi.object({ 81 | extended: Joi.any(), 82 | purge: Joi.boolean().default(true), 83 | secondary: Joi.array().items(Joi.string(), internals.secondaryIndex).single().default([]).allow(false), 84 | id: [ 85 | { 86 | type: 'uuid' 87 | }, 88 | { 89 | type: 'increment', 90 | table: Joi.string().default('penseur_id_allocate'), 91 | record: Joi.string(), // Defaults to table name 92 | key: Joi.string().default('value'), 93 | initial: Joi.number().integer().min(0).default(1), 94 | radix: Joi.number().integer().min(2).max(36).allow(62).default(10) 95 | } 96 | ], 97 | unique: Joi.array().items(internals.unique).min(1).single(), 98 | primary: Joi.string().max(127), 99 | geo: Joi.boolean().default(false) 100 | }) 101 | .allow(true, false) 102 | }; 103 | 104 | 105 | exports = module.exports = internals.Db = class { 106 | 107 | constructor(name, options) { 108 | 109 | this._settings = Hoek.cloneWithShallow(Joi.attempt(options || {}, internals.schema.db, 'Invalid database options'), ['test']); 110 | this.name = name; 111 | this._connection = null; 112 | this._connectionOptions = null; 113 | this._feeds = {}; // uuid -> { table, criteria, options } 114 | 115 | if (this._settings.test) { 116 | this.disable = internals.disable; 117 | this.enable = internals.enable; 118 | } 119 | 120 | this.tables = {}; 121 | this.r = RethinkDB; 122 | } 123 | 124 | async connect() { 125 | 126 | await this._connect(); 127 | const exists = await this._exists(); 128 | 129 | if (!exists) { 130 | this.close(); 131 | throw Boom.internal(`Missing database: ${this.name}`); 132 | } 133 | 134 | await this._verify(); 135 | 136 | // Reconnect changes feeds 137 | 138 | const feeds = Object.keys(this._feeds); 139 | for (let i = 0; i < feeds.length; ++i) { 140 | const feedId = feeds[i]; 141 | const feed = this._feeds[feedId]; 142 | await feed.table.changes(feed.criteria, feed.options); 143 | } 144 | } 145 | 146 | async _connect() { 147 | 148 | const settings = this._connectionOptions || {}; 149 | if (!this._connectionOptions) { 150 | ['host', 'port', 'db', 'authKey', 'timeout', 'ssl', 'user', 'password'].forEach((item) => { 151 | 152 | if (this._settings[item] !== undefined) { 153 | settings[item] = this._settings[item]; 154 | } 155 | }); 156 | } 157 | 158 | const connection = await RethinkDB.connect(settings); 159 | this._connection = connection; 160 | this._connectionOptions = settings; 161 | 162 | this._connection.on('error', (err) => this._settings.onError(err)); 163 | this._connection.on('timeout', () => this._settings.onError(Boom.internal('Database connection timeout'))); 164 | this._connection.once('close', async () => { 165 | 166 | const reconnect = this._willReconnect(); 167 | this._settings.onDisconnect(reconnect); 168 | 169 | if (!reconnect) { 170 | return; 171 | } 172 | 173 | let first = true; 174 | const loop = async (err) => { 175 | 176 | first = false; 177 | await Hoek.wait(this._settings.reconnectTimeout && !first ? this._settings.reconnectTimeout : 0); 178 | 179 | this._settings.onError(err); 180 | try { 181 | await this.connect(); 182 | } 183 | catch (err) { 184 | Bounce.rethrow(err, 'system'); 185 | await loop(err); 186 | } 187 | }; 188 | 189 | try { 190 | await this.connect(); 191 | } 192 | catch (err) { 193 | Bounce.rethrow(err, 'system'); 194 | await loop(err); 195 | } 196 | }); 197 | 198 | this._settings.onConnect(); 199 | } 200 | 201 | _willReconnect() { 202 | 203 | return (this._settings.reconnect && !!this._connectionOptions); 204 | } 205 | 206 | async close() { 207 | 208 | this._connectionOptions = null; // Stop reconnections 209 | 210 | if (!this._connection) { 211 | return; 212 | } 213 | 214 | // Close change stream cursors 215 | 216 | Object.keys(this.tables).forEach((name) => { 217 | 218 | const table = this.tables[name]; 219 | table._cursors.forEach((cursor) => cursor.close()); 220 | table._cursors = []; 221 | }); 222 | 223 | // Close connection 224 | 225 | await this._connection.close(); 226 | if (this._connection) { 227 | this._connection.removeAllListeners(); 228 | this._connection = null; 229 | } 230 | } 231 | 232 | table(tables, options) { 233 | 234 | const byName = this._normalizeTables(tables, options); 235 | const names = Object.keys(byName); 236 | names.forEach((name) => { 237 | 238 | if (this.tables[name]) { 239 | return; 240 | } 241 | 242 | let tableOptions = byName[name]; 243 | if (!tableOptions) { 244 | return; 245 | } 246 | 247 | if (tableOptions === true) { 248 | tableOptions = {}; 249 | } 250 | 251 | // Decorate object with tables 252 | 253 | const record = this._generateTable(name, tableOptions); 254 | this.tables[name] = record; 255 | if (!this[name] && 256 | name[0] !== '_') { // Do not override prototype or private members 257 | 258 | this[name] = record; 259 | } 260 | }); 261 | } 262 | 263 | _normalizeTables(tables, options) { 264 | 265 | if (!tables) { 266 | const byName = {}; 267 | Object.keys(this.tables).forEach((name) => { 268 | 269 | byName[name] = this.tables[name]._settings; 270 | }); 271 | 272 | return byName; 273 | } 274 | 275 | const normalize = (opts) => { 276 | 277 | if (opts.id && 278 | typeof opts.id === 'string') { 279 | 280 | opts = Object.assign({}, opts); // Shallow cloned 281 | opts.id = { type: opts.id }; 282 | } 283 | 284 | return opts; 285 | }; 286 | 287 | // String or array of strings 288 | 289 | if (typeof tables === 'string' || 290 | Array.isArray(tables)) { 291 | 292 | options = Joi.attempt(normalize(options || {}), internals.schema.table, 'Invalid table options'); 293 | 294 | const byName = {}; 295 | [].concat(tables).forEach((table) => { 296 | 297 | byName[table] = options; 298 | }); 299 | 300 | return byName; 301 | } 302 | 303 | // Object { name: { options } } 304 | 305 | Hoek.assert(!options, 'Cannot specify options with tables object'); 306 | 307 | tables = Object.assign({}, tables); // Shallow cloned 308 | const names = Object.keys(tables); 309 | names.forEach((name) => { 310 | 311 | tables[name] = Joi.attempt(normalize(tables[name]), internals.schema.table, `Invalid table options: ${name}`); 312 | }); 313 | 314 | return tables; 315 | } 316 | 317 | _generateTable(name, options) { 318 | 319 | options = options || {}; 320 | const Proto = options.extended || this._settings.extended || Table; 321 | return new Proto(name, this, options); 322 | } 323 | 324 | async establish(tables) { 325 | 326 | if (!this._connection) { 327 | await this._connect(); 328 | return this.establish(tables); 329 | } 330 | 331 | const byName = this._normalizeTables(tables); 332 | this.table(byName); 333 | 334 | const exists = await this._exists(); 335 | if (!exists) { 336 | await RethinkDB.dbCreate(this.name).run(this._connection); 337 | } 338 | 339 | await this._createTable(byName); 340 | return this._verify(); 341 | } 342 | 343 | async _exists() { 344 | 345 | const names = await RethinkDB.dbList().run(this._connection); 346 | return (names.indexOf(this.name) !== -1); 347 | } 348 | 349 | async _createTable(tables) { 350 | 351 | const configs = await RethinkDB.db(this.name).tableList().map((table) => RethinkDB.db(this.name).table(table).config()).run(this._connection); 352 | 353 | const existing = {}; 354 | configs.forEach((config) => { 355 | 356 | existing[config.name] = config; 357 | }); 358 | 359 | const names = Object.keys(tables); 360 | for (let i = 0; i < names.length; ++i) { 361 | const name = names[i]; 362 | 363 | let tableOptions = tables[name]; 364 | if (tableOptions === false) { 365 | continue; 366 | } 367 | 368 | if (tableOptions === true) { 369 | tableOptions = {}; 370 | } 371 | 372 | // Check primary key 373 | 374 | const primaryKey = tableOptions.primary || 'id'; 375 | const existingConfig = existing[name]; 376 | let drop = false; 377 | if (existingConfig && 378 | existingConfig.primary_key !== primaryKey) { 379 | 380 | drop = RethinkDB.db(this.name).tableDrop(name); 381 | } 382 | 383 | // Create new table 384 | 385 | if (!existingConfig || 386 | drop) { 387 | 388 | const create = RethinkDB.db(this.name).tableCreate(name, { primaryKey }); 389 | const change = (drop ? RethinkDB.and(drop, create) : create); 390 | await change.run(this._connection); 391 | } 392 | else { 393 | 394 | // Reuse existing table 395 | 396 | if (tableOptions.purge !== false) { // Defaults to true 397 | await this.tables[name].empty(); 398 | } 399 | 400 | if (tableOptions.secondary !== false) { // false means leave as-is (vs null or empty array which drops existing) 401 | for (let j = 0; j < existingConfig.indexes.length; ++j) { 402 | const index = existingConfig.indexes[j]; 403 | await RethinkDB.db(this.name).table(name).indexDrop(index).run(this._connection); 404 | } 405 | } 406 | } 407 | 408 | if (!tableOptions.secondary) { 409 | continue; 410 | } 411 | 412 | await this.tables[name].index(tableOptions.secondary); 413 | } 414 | } 415 | 416 | async _verify() { 417 | 418 | const names = Object.keys(this.tables); 419 | for (let i = 0; i < names.length; ++i) { 420 | const name = names[i]; 421 | const table = this.tables[name]; 422 | await Id.verify(table, { allocate: false }); 423 | await Unique.verify(table); 424 | } 425 | } 426 | 427 | async run(request, options = {}) { 428 | 429 | // Extract table name from ReQL object 430 | 431 | let ref = request; 432 | let table; 433 | while (ref.args.length) { 434 | if (ref.args[1]) { 435 | table = ref.args[1].data; 436 | } 437 | 438 | ref = ref.args[0]; 439 | } 440 | 441 | const track = { table, action: 'run' }; 442 | 443 | try { 444 | return await this._run(request, track, options); 445 | } 446 | catch (err) { 447 | throw Table.error(err, track); 448 | } 449 | } 450 | 451 | async _run(request, track, options) { 452 | 453 | if (!this._connection) { 454 | throw new Boom('Database disconnected'); 455 | } 456 | 457 | if (this._settings.test && 458 | typeof this._settings.test === 'object') { 459 | 460 | const { table, action, inputs = null } = track; 461 | this._settings.test[table] = this._settings.test[table] || []; 462 | this._settings.test[table].push({ action, inputs }); 463 | } 464 | 465 | const result = await request.run(this._connection, options); 466 | if (result === null) { 467 | return null; 468 | } 469 | 470 | if (result.errors) { 471 | throw new Boom(result.first_error); 472 | } 473 | 474 | // Single item 475 | 476 | if (typeof result.toArray !== 'function' || 477 | Array.isArray(result)) { 478 | 479 | return internals.empty(result); 480 | } 481 | 482 | // Cursor 483 | 484 | const results = await result.toArray(); 485 | result.close(); 486 | return internals.empty(results); 487 | } 488 | 489 | // Criteria 490 | 491 | static or(values) { 492 | 493 | return new Special('or', values); 494 | } 495 | 496 | static contains(values, options) { 497 | 498 | return new Special('contains', values, options); 499 | } 500 | 501 | static match(values, options) { 502 | 503 | return new Special('match', values, options); 504 | } 505 | 506 | static near(coordinates, distance, unit) { 507 | 508 | return new Special('near', coordinates, { distance, unit }); 509 | } 510 | 511 | static not(values) { 512 | 513 | return new Special('or', [].concat(values), { not: true }); 514 | } 515 | 516 | static is(operator, value, ...and) { 517 | 518 | const comparator = internals.comparators[operator]; 519 | Hoek.assert(comparator, `Unknown comparator: ${operator}`); 520 | Hoek.assert(value !== undefined, 'Missing value argument'); 521 | 522 | const flags = { comparator }; 523 | if (and.length) { 524 | Hoek.assert(!(and.length % 2), 'Cannot have odd number of arguments'); 525 | 526 | flags.and = []; 527 | for (let i = 0; i < and.length; i += 2) { 528 | const c = internals.comparators[and[i]]; 529 | const v = and[i + 1]; 530 | 531 | Hoek.assert(v !== undefined, 'Missing value argument'); 532 | Hoek.assert(c, `Unknown comparator: ${and[i]}`); 533 | 534 | flags.and.push({ comparator: c, value: v }); 535 | } 536 | } 537 | 538 | return new Special('is', value, flags); 539 | } 540 | 541 | static by(index, values) { 542 | 543 | return new Special('by', [].concat(values), { index }); 544 | } 545 | 546 | static empty() { 547 | 548 | return new Special('empty'); 549 | } 550 | 551 | // Criteria or Modifier 552 | 553 | static unset() { 554 | 555 | return new Special('unset'); 556 | } 557 | 558 | // Modifier 559 | 560 | static increment(value) { 561 | 562 | return new Special('increment', value); 563 | } 564 | 565 | static append(value, options = {}) { // { single: false, create: false, unique: false } 566 | 567 | if (options.unique) { // true, false, 'any', 'last', { match, path } 568 | Hoek.assert(options.single || !Array.isArray(value), 'Cannot append multiple values with unique requirements'); 569 | 570 | if (typeof options.unique !== 'object') { 571 | options = Object.assign({}, options); // Shallow clone 572 | 573 | if (options.unique === true) { 574 | options.unique = { match: 'any' }; // match: any, last 575 | } 576 | else { 577 | options.unique = { match: options.unique }; 578 | } 579 | } 580 | 581 | options.unique.match = options.unique.match || 'any'; 582 | } 583 | 584 | return new Special('append', value, options); 585 | } 586 | 587 | static override(value) { 588 | 589 | return new Special('override', value); 590 | } 591 | 592 | static isSpecial(value) { 593 | 594 | return Special.isSpecial(value) ? value.type : null; 595 | } 596 | }; 597 | 598 | 599 | internals.Db.prototype.append = internals.Db.append; 600 | internals.Db.prototype.by = internals.Db.by; 601 | internals.Db.prototype.contains = internals.Db.contains; 602 | internals.Db.prototype.empty = internals.Db.empty; 603 | internals.Db.prototype.increment = internals.Db.increment; 604 | internals.Db.prototype.is = internals.Db.is; 605 | internals.Db.prototype.match = internals.Db.match; 606 | internals.Db.prototype.near = internals.Db.near; 607 | internals.Db.prototype.not = internals.Db.not; 608 | internals.Db.prototype.or = internals.Db.or; 609 | internals.Db.prototype.override = internals.Db.override; 610 | internals.Db.prototype.unset = internals.Db.unset; 611 | 612 | 613 | internals.Db.prototype.isSpecial = internals.Db.isSpecial; 614 | 615 | 616 | internals.disable = function (table, method, options) { 617 | 618 | options = options || {}; 619 | 620 | Hoek.assert(this.tables[table], 'Unknown table:', table); 621 | Hoek.assert(this.tables[table][method], 'Unknown method:', method); 622 | 623 | if (method === 'changes' && 624 | options.updates) { 625 | 626 | this.tables[table].changes = function (criteria, changesOptions) { 627 | 628 | if (typeof changesOptions !== 'object') { 629 | changesOptions = { handler: changesOptions }; 630 | } 631 | 632 | const error = Table.error(Boom.internal('Simulated database error'), { table, action: method }); 633 | error.flags = Hoek.applyToDefaults({ willReconnect: true, disconnected: true }, options.flags || {}); 634 | process.nextTick(() => changesOptions.handler(error)); 635 | return { close: Hoek.ignore }; 636 | }; 637 | 638 | return; 639 | } 640 | 641 | this.tables[table][method] = internals.disabled(table, method, options); 642 | }; 643 | 644 | 645 | internals.disabled = function (table, method, options) { 646 | 647 | const value = options.value; 648 | 649 | return function () { 650 | 651 | if (value !== undefined) { 652 | if (value instanceof Error) { 653 | return Promise.reject(value); 654 | } 655 | 656 | return value; 657 | } 658 | 659 | return Promise.reject(Table.error(Boom.internal('Simulated database error'), { table, action: method })); 660 | }; 661 | }; 662 | 663 | 664 | internals.enable = function (table, method) { 665 | 666 | Hoek.assert(this.tables[table], 'Unknown table:', table); 667 | Hoek.assert(this.tables[table][method], 'Unknown method:', method); 668 | 669 | this.tables[table][method] = Table.prototype[method]; 670 | }; 671 | 672 | 673 | internals.empty = function (results) { 674 | 675 | if (!results || 676 | !Array.isArray(results)) { 677 | 678 | return results; 679 | } 680 | 681 | return (results.length ? results : null); 682 | }; 683 | -------------------------------------------------------------------------------- /lib/geo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('hoek'); 4 | const RethinkDB = require('rethinkdb'); 5 | 6 | const Special = require('./special'); 7 | 8 | 9 | const internals = {}; 10 | 11 | 12 | exports.index = function (options) { 13 | 14 | if (!options.secondary || 15 | !options.geo) { 16 | 17 | return null; 18 | } 19 | 20 | const indexes = []; 21 | for (let i = 0; i < options.secondary.length; ++i) { 22 | const index = options.secondary[i]; 23 | if (index.options && 24 | index.options.geo) { 25 | 26 | Hoek.assert(typeof index.source !== 'function', 'Cannot use geo index function source'); 27 | indexes.push({ name: index.name, source: index.source || [index.name] }); 28 | } 29 | } 30 | 31 | return indexes; 32 | }; 33 | 34 | 35 | exports.read = function (items, table) { 36 | 37 | return internals.scan(items, table, false, (field) => { 38 | 39 | if (field.$reql_type$ === 'GEOMETRY' && 40 | field.type === 'Point') { 41 | 42 | return field.coordinates; 43 | } 44 | }); 45 | }; 46 | 47 | 48 | exports.write = function (items, table) { 49 | 50 | return internals.scan(items, table, true, (field) => { 51 | 52 | if (Array.isArray(field) && 53 | field.length === 2) { 54 | 55 | return { $reql_type$: 'GEOMETRY', coordinates: field, type: 'Point' }; 56 | } 57 | }); 58 | }; 59 | 60 | 61 | exports.select = function (items, table) { 62 | 63 | let near = null; 64 | internals.scan(items, table, false, (field, index) => { 65 | 66 | if (!Special.isSpecial(field) || 67 | field.type !== 'near') { 68 | 69 | return field; 70 | } 71 | 72 | if (near) { 73 | throw new Error('Cannot specify more than one near condition'); 74 | } 75 | 76 | near = { field, index }; 77 | return field; 78 | }); 79 | 80 | if (!near) { 81 | return table.raw; 82 | } 83 | 84 | const area = RethinkDB.circle(RethinkDB.point(near.field.value[0], near.field.value[1]), near.field.flags.distance, { unit: near.field.flags.unit || 'm' }); 85 | return table.raw.getIntersecting(area, { index: near.index }); 86 | }; 87 | 88 | 89 | internals.scan = function (items, table, clone, each) { 90 | 91 | if (!items) { 92 | return items; 93 | } 94 | 95 | const result = []; 96 | const isArray = Array.isArray(items); 97 | const array = isArray ? items : [items]; 98 | for (let i = 0; i < array.length; ++i) { 99 | let item = array[i]; 100 | for (let j = 0; j < table._geo.length; ++j) { 101 | const index = table._geo[j]; 102 | const path = index.source; 103 | 104 | let ref = item; 105 | let key = null; 106 | 107 | for (let k = 0; ; ++k) { 108 | key = path[k]; 109 | 110 | if (k === path.length - 1 || 111 | !ref || 112 | typeof ref !== 'object') { 113 | 114 | break; 115 | } 116 | 117 | ref = ref[key]; 118 | } 119 | 120 | if (ref && 121 | ref[key]) { 122 | 123 | const override = each(ref[key], index.name); 124 | if (override === undefined) { 125 | continue; 126 | } 127 | 128 | if (!clone) { 129 | ref[key] = override; 130 | } 131 | else { 132 | item = Hoek.clone(item); 133 | internals.override(item, path, override); 134 | } 135 | } 136 | } 137 | 138 | result.push(item); 139 | } 140 | 141 | return (isArray ? result : result[0]); 142 | }; 143 | 144 | 145 | internals.override = function (item, path, override) { 146 | 147 | let ref = item; 148 | for (let i = 0; i < path.length - 1; ++i) { 149 | ref = ref[path[i]]; 150 | } 151 | 152 | ref[path[path.length - 1]] = override; 153 | }; 154 | -------------------------------------------------------------------------------- /lib/id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Crypto = require('crypto'); 4 | const Boom = require('boom'); 5 | const Radix62 = require('radix62'); 6 | 7 | 8 | const internals = { 9 | byteToHex: [] 10 | }; 11 | 12 | 13 | internals.buildCache = function () { 14 | 15 | for (let i = 0; i < 256; ++i) { 16 | const hex = (i < 16 ? '0' : '') + i.toString(16); 17 | internals.byteToHex[i] = hex; 18 | } 19 | }; 20 | 21 | internals.buildCache(); 22 | 23 | 24 | exports.is = function (id) { 25 | 26 | if (id === null || 27 | id === undefined) { 28 | 29 | return false; 30 | } 31 | 32 | if (typeof id !== 'object') { 33 | return true; 34 | } 35 | 36 | return Object.keys(id).length === 1 && id.id !== undefined; 37 | }; 38 | 39 | 40 | exports.extract = function (items, table) { 41 | 42 | return items.map((item) => { 43 | 44 | const id = item[table.primary]; 45 | return (typeof id === 'object' ? { id } : id); 46 | }); 47 | }; 48 | 49 | 50 | exports.normalize = function (ids, { allowArray }) { 51 | 52 | if (!Array.isArray(ids)) { 53 | return internals.validate(ids); 54 | } 55 | 56 | if (!allowArray) { 57 | throw new Boom('Array of ids not supported'); 58 | } 59 | 60 | if (!ids.length) { 61 | throw new Boom('Empty array of ids not supported'); 62 | } 63 | 64 | return ids.map(internals.validate); 65 | }; 66 | 67 | 68 | internals.validate = function (id) { 69 | 70 | if (id === null || 71 | id === undefined) { 72 | 73 | throw new Boom('Invalid null or undefined id'); 74 | } 75 | 76 | if (typeof id === 'object') { // Id expressed as { id } (unrelated to the id field name of the table) 77 | if (id.id === undefined || 78 | Object.keys(id).length > 1) { 79 | 80 | throw new Boom('Invalid object id'); 81 | } 82 | 83 | id = id.id; 84 | } 85 | 86 | if (typeof id === 'string' && 87 | id.length > 127) { 88 | 89 | throw new Boom(`Invalid id length: ${id}`); 90 | } 91 | 92 | return id; 93 | }; 94 | 95 | 96 | exports.compile = function (table, options) { 97 | 98 | if (!options) { 99 | return false; 100 | } 101 | 102 | const settings = { 103 | type: options.type, 104 | verified: options.type === 'uuid' // UUID requires no verification 105 | }; 106 | 107 | if (settings.type === 'increment') { 108 | settings.table = table._db._generateTable(options.table); 109 | settings.record = options.record || table.name; 110 | settings.key = options.key; 111 | settings.initial = options.initial; 112 | settings.radix = options.radix; 113 | } 114 | 115 | return settings; 116 | }; 117 | 118 | 119 | exports.wrap = async function (table, items) { 120 | 121 | if (!table._id) { 122 | return items; 123 | } 124 | 125 | const result = []; 126 | const identifiers = []; 127 | [].concat(items).forEach((item) => { 128 | 129 | if (item[table.primary] === undefined) { 130 | item = Object.assign({}, item); // Shallow cloned 131 | identifiers.push(item); 132 | } 133 | 134 | result.push(item); 135 | }); 136 | 137 | if (!identifiers.length) { 138 | return items; 139 | } 140 | 141 | for (let i = 0; i < identifiers.length; ++i) { 142 | const identifier = identifiers[i]; 143 | identifier[table.primary] = await internals[table._id.type](table); 144 | } 145 | 146 | return (Array.isArray(items) ? result : result[0]); 147 | }; 148 | 149 | 150 | exports.uuid = function () { 151 | 152 | // Based on node-uuid - https://github.com/broofa/node-uuid - Copyright (c) 2010-2012 Robert Kieffer - MIT License 153 | 154 | const b = internals.byteToHex; 155 | const buf = Crypto.randomBytes(16); 156 | 157 | buf[6] = (buf[6] & 0x0f) | 0x40; // Per RFC 4122 (4.4) - set bits for version and clock_seq_hi_and_reserved 158 | buf[8] = (buf[8] & 0x3f) | 0x80; 159 | 160 | return (b[buf[0]] + b[buf[1]] + b[buf[2]] + b[buf[3]] + '-' + 161 | b[buf[4]] + b[buf[5]] + '-' + 162 | b[buf[6]] + b[buf[7]] + '-' + 163 | b[buf[8]] + b[buf[9]] + '-' + 164 | b[buf[10]] + b[buf[11]] + b[buf[12]] + b[buf[13]] + b[buf[14]] + b[buf[15]]); 165 | }; 166 | 167 | 168 | internals.uuid = function (table) { 169 | 170 | return exports.uuid(); 171 | }; 172 | 173 | 174 | internals.increment = async function (table) { 175 | 176 | const allocated = await exports.verify(table, { allocate: true }); 177 | 178 | if (allocated) { 179 | return internals.radix(allocated, table._id.radix); 180 | } 181 | 182 | try { 183 | const value = await table._id.table.next(table._id.record, table._id.key, 1); 184 | return internals.radix(value, table._id.radix); 185 | } 186 | catch (err) { 187 | err.message = `Failed allocating increment id: ${table.name}`; 188 | throw err; 189 | } 190 | }; 191 | 192 | 193 | internals.radix = function (value, radix) { 194 | 195 | if (radix <= 36) { 196 | return value.toString(radix); 197 | } 198 | 199 | return Radix62.to(value); 200 | }; 201 | 202 | 203 | exports.verify = async function (table, options) { 204 | 205 | if (!table._id || 206 | table._id.verified) { 207 | 208 | return; 209 | } 210 | 211 | const create = {}; 212 | create[table._id.table.name] = { purge: false, secondary: false }; 213 | try { 214 | await table._db._createTable(create); 215 | } 216 | catch (err) { 217 | err.message = `Failed creating increment id table: ${table.name}`; 218 | throw err; 219 | } 220 | 221 | let record; 222 | try { 223 | record = await table._id.table.get(table._id.record); 224 | } 225 | catch (err) { 226 | err.message = `Failed verifying increment id record: ${table.name}`; 227 | throw err; 228 | } 229 | 230 | // Record found 231 | 232 | let initialId = table._id.initial - 1; 233 | let allocatedId = null; 234 | 235 | if (options.allocate) { 236 | ++initialId; 237 | allocatedId = initialId; 238 | } 239 | 240 | if (record) { 241 | if (record[table._id.key] === undefined) { 242 | 243 | // Set key 244 | 245 | const changes = {}; 246 | changes[table._id.key] = initialId; 247 | try { 248 | await table._id.table.update(table._id.record, changes); 249 | } 250 | catch (err) { 251 | err.message = `Failed initializing key-value pair to increment id record: ${table.name}`; 252 | throw err; 253 | } 254 | 255 | table._id.verified = true; 256 | return allocatedId; 257 | } 258 | 259 | if (!Number.isSafeInteger(record[table._id.key])) { 260 | throw Boom.internal(`Increment id record contains non-integer value: ${table.name}`); 261 | } 262 | 263 | table._id.verified = true; 264 | return; 265 | } 266 | 267 | // Insert record 268 | 269 | const item = { id: table._id.record }; 270 | item[table._id.key] = initialId; 271 | try { 272 | await table._id.table.insert(item); 273 | table._id.verified = true; 274 | return allocatedId; 275 | } 276 | catch (err) { 277 | err.message = `Failed inserting increment id record: ${table.name}`; 278 | throw err; 279 | } 280 | }; 281 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const RethinkDb = require('rethinkdb'); 4 | 5 | const Db = require('./db'); 6 | const Table = require('./table'); 7 | const Utils = require('./utils'); 8 | 9 | 10 | const internals = {}; 11 | 12 | 13 | module.exports = { 14 | Db, 15 | Table, 16 | utils: Utils, 17 | r: RethinkDb 18 | }; 19 | -------------------------------------------------------------------------------- /lib/modifier.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('hoek'); 4 | const RethinkDB = require('rethinkdb'); 5 | 6 | const Special = require('./special'); 7 | 8 | 9 | const internals = {}; 10 | 11 | 12 | exports.wrap = function (changes, table) { 13 | 14 | const pairs = {}; 15 | 16 | if (Array.isArray(changes)) { 17 | for (const change of changes) { 18 | const key = change[table.primary]; 19 | const id = (typeof key === 'string' ? key : JSON.stringify(key)); 20 | const existing = pairs[id]; 21 | if (!existing) { 22 | pairs[id] = change; 23 | } 24 | else { 25 | const base = Hoek.clone(existing); 26 | pairs[id] = Hoek.merge(base, change, true, false); 27 | } 28 | } 29 | } 30 | 31 | return function (item) { 32 | 33 | const each = (change) => { 34 | 35 | const without = []; 36 | const wrapped = internals.wrap(change, without, item, []); 37 | 38 | let update = item; 39 | if (without.length) { 40 | update = update.without(without); 41 | } 42 | 43 | if (wrapped) { 44 | update = update.merge(wrapped); 45 | } 46 | 47 | return RethinkDB.branch(RethinkDB.eq(item, null), RethinkDB.error('No document found'), update); 48 | }; 49 | 50 | if (!Array.isArray(changes)) { 51 | return each(changes); 52 | } 53 | 54 | for (const id in pairs) { 55 | pairs[id] = each(pairs[id]); 56 | } 57 | 58 | return RethinkDB.expr(pairs)(item(table.primary).coerceTo('string')); 59 | }; 60 | }; 61 | 62 | 63 | internals.wrap = function (changes, without, item, path) { 64 | 65 | if (Special.isSpecial(changes)) { 66 | const { type, value, flags } = changes; 67 | 68 | // Unset 69 | 70 | if (type === 'unset') { 71 | without.push(internals.select(path)); 72 | return undefined; 73 | } 74 | 75 | // Override 76 | 77 | if (type === 'override') { 78 | without.push(internals.select(path)); 79 | return internals.wrap(value, without, item, path); 80 | } 81 | 82 | // Append 83 | 84 | const current = internals.path(item, path); 85 | let changed = current; 86 | 87 | if (type === 'append') { 88 | if (flags.single || 89 | !Array.isArray(value)) { 90 | 91 | changed = changed.append(value); 92 | } 93 | else { 94 | for (const v of value) { 95 | changed = changed.append(v); 96 | } 97 | } 98 | 99 | if (flags.unique) { 100 | let match; 101 | 102 | const upath = flags.unique.path; 103 | if (upath) { 104 | const innerValue = Hoek.reach(value, upath); 105 | if (flags.unique.match === 'any') { 106 | match = current.offsetsOf((v) => internals.path(v, upath).eq(innerValue)).count().eq(0); 107 | } 108 | else { 109 | match = internals.path(current, upath).nth(-1).ne(innerValue); 110 | } 111 | } 112 | else { 113 | if (flags.unique.match === 'any') { 114 | match = current.offsetsOf(value).count().eq(0); 115 | } 116 | else { 117 | match = current.nth(-1).ne(value); 118 | } 119 | } 120 | 121 | changed = RethinkDB.branch(current.count().eq(0), changed, RethinkDB.branch(match, changed, current)); 122 | } 123 | 124 | if (flags.create) { 125 | const check = internals.path(item, path, 1); 126 | changed = RethinkDB.branch(check.hasFields(path[path.length - 1]), changed, [value]); 127 | } 128 | 129 | return changed; 130 | } 131 | 132 | // Increment 133 | 134 | return changed.add(value); 135 | } 136 | 137 | if (typeof changes !== 'object' || 138 | changes === null) { 139 | 140 | return changes; 141 | } 142 | 143 | let ref = changes; // Try using the same object 144 | 145 | for (const key in changes) { 146 | const value = changes[key]; 147 | const wrapped = internals.wrap(value, without, item, path.concat(key)); 148 | if (wrapped === value) { 149 | continue; 150 | } 151 | 152 | if (ref === changes) { 153 | ref = Object.assign({}, changes); // Shallow clone before making changes 154 | } 155 | 156 | if (wrapped === undefined) { 157 | delete ref[key]; 158 | } 159 | else { 160 | ref[key] = wrapped; 161 | } 162 | } 163 | 164 | if (Object.keys(changes).length && 165 | !Array.isArray(ref) && 166 | !Object.keys(ref).length) { 167 | 168 | return undefined; 169 | } 170 | 171 | return ref; 172 | }; 173 | 174 | 175 | internals.select = function (path) { 176 | 177 | const selection = {}; 178 | let current = selection; 179 | for (let i = 0; i < path.length; ++i) { 180 | const key = path[i]; 181 | current[key] = (i === path.length - 1 ? true : {}); 182 | current = current[key]; 183 | } 184 | 185 | return selection; 186 | }; 187 | 188 | 189 | internals.path = function (ref, path, offset = 0) { 190 | 191 | for (let i = 0; i < path.length - offset; ++i) { 192 | ref = ref(path[i]); 193 | } 194 | 195 | return ref; 196 | }; 197 | -------------------------------------------------------------------------------- /lib/special.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('hoek'); 4 | 5 | 6 | const internals = {}; 7 | 8 | 9 | module.exports = internals.Special = class { 10 | 11 | constructor(type, value, options) { 12 | 13 | this.type = type; 14 | this.flags = (options ? Hoek.clone(options) : {}); 15 | 16 | if (value !== undefined) { 17 | this.value = value; 18 | } 19 | } 20 | 21 | static isSpecial(value) { 22 | 23 | return value instanceof internals.Special; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /lib/table.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('boom'); 4 | const Hoek = require('hoek'); 5 | const RethinkDB = require('rethinkdb'); 6 | 7 | const Cursor = require('./cursor'); 8 | const Criteria = require('./criteria'); 9 | const Geo = require('./geo'); 10 | const Id = require('./id'); 11 | const Modifier = require('./modifier'); 12 | const Unique = require('./unique'); 13 | 14 | 15 | const internals = { 16 | changeTypes: { 17 | add: 'insert', 18 | remove: 'remove', 19 | change: 'update', 20 | initial: 'initial' 21 | // uninitial: can only happen in sorted requests which are not supported 22 | } 23 | }; 24 | 25 | 26 | exports = module.exports = internals.Table = class { 27 | 28 | constructor(name, db, options) { 29 | 30 | this.name = name; 31 | this.primary = options.primary || 'id'; 32 | this._db = db; 33 | this._id = Id.compile(this, options.id); 34 | this._unique = Unique.compile(this, options.unique); 35 | this._geo = Geo.index(options); 36 | this._settings = Hoek.cloneWithShallow(options, ['extended']); // Used by db.establish() 37 | 38 | this.raw = RethinkDB.db(this._db.name).table(name); 39 | this._cursors = []; 40 | } 41 | 42 | async get(ids, options = {}) { 43 | 44 | const track = { table: this.name, action: 'get', inputs: { ids, options } }; 45 | 46 | try { 47 | const batch = Array.isArray(ids); 48 | ids = Id.normalize(ids, { allowArray: true }); 49 | const query = (batch ? this.raw.getAll(RethinkDB.args(ids)) : this.raw.get(ids)); 50 | const items = await this._db._run(this._refine(query, options), track); 51 | return this._read(items); 52 | } 53 | catch (err) { 54 | throw internals.Table.error(err, track); 55 | } 56 | } 57 | 58 | _refine(selection, options, fullTable) { 59 | 60 | if (options.sort || 61 | options.from !== undefined || // Consider explicit start from zero as sorted 62 | options.count) { 63 | 64 | const sort = (options.sort ? (typeof options.sort === 'string' || Array.isArray(options.sort) ? { key: options.sort } : options.sort) : { key: this.primary }); 65 | const descending = (sort.order === 'descending'); 66 | const key = (typeof sort.key === 'string' ? (!descending && sort.key === this.primary && fullTable ? { index: sort.key } : sort.key) : Criteria.row(sort.key)); 67 | const by = (descending ? RethinkDB.desc(key) : key); 68 | selection = selection.orderBy(by); 69 | } 70 | 71 | if (options.from) { 72 | selection = selection.skip(options.from); 73 | } 74 | 75 | if (options.count) { 76 | selection = selection.limit(options.count); 77 | } 78 | 79 | if (options.filter) { 80 | selection = selection.pluck(options.filter); 81 | } 82 | 83 | return selection; 84 | } 85 | 86 | async all(options = {}) { 87 | 88 | const track = { table: this.name, action: 'all' }; 89 | 90 | try { 91 | const items = await this._db._run(this._refine(this.raw, options, true), track); 92 | return this._read(items); 93 | } 94 | catch (err) { 95 | throw internals.Table.error(err, track); 96 | } 97 | } 98 | 99 | async exist(id) { 100 | 101 | const track = { table: this.name, action: 'exist', inputs: { id } }; 102 | 103 | try { 104 | id = Id.normalize(id, { allowArray: false }); 105 | return await this._db._run(this.raw.get(id).ne(null), track); 106 | } 107 | catch (err) { 108 | throw internals.Table.error(err, track); 109 | } 110 | } 111 | 112 | async distinct(criteria, fields) { 113 | 114 | if (!fields) { 115 | fields = criteria; 116 | criteria = null; 117 | } 118 | 119 | fields = [].concat(fields); 120 | 121 | const track = { table: this.name, action: 'distinct', inputs: { criteria, fields } }; 122 | 123 | try { 124 | const selection = Criteria.select(criteria, this).pluck(fields).distinct(); 125 | const result = await this._db._run(selection, track); 126 | if (!result) { 127 | return null; 128 | } 129 | 130 | if (fields.length === 1) { 131 | return result.map((item) => item[fields[0]]); 132 | } 133 | 134 | return result; 135 | } 136 | catch (err) { 137 | throw internals.Table.error(err, track); 138 | } 139 | } 140 | 141 | async query(criteria, options = {}) { 142 | 143 | const track = { table: this.name, action: 'query', inputs: { criteria, options } }; 144 | 145 | try { 146 | const selection = Criteria.select(criteria, this, options); 147 | const items = await this._db._run(this._refine(selection, options), track); 148 | return this._read(items); 149 | } 150 | catch (err) { 151 | throw internals.Table.error(err, track); 152 | } 153 | } 154 | 155 | async single(criteria) { 156 | 157 | const track = { table: this.name, action: 'single', inputs: { criteria } }; 158 | 159 | try { 160 | const result = await this._db._run(Criteria.select(criteria, this), track); 161 | if (!result) { 162 | return null; 163 | } 164 | 165 | if (result.length !== 1) { 166 | throw new Boom('Found multiple items'); 167 | } 168 | 169 | return this._read(result[0]); 170 | } 171 | catch (err) { 172 | throw internals.Table.error(err, track); 173 | } 174 | } 175 | 176 | async count(criteria = null) { 177 | 178 | const track = { table: this.name, action: 'count', inputs: { criteria } }; 179 | 180 | try { 181 | return await this._db._run(Criteria.select(criteria, this).count(), track); 182 | } 183 | catch (err) { 184 | throw internals.Table.error(err, track); 185 | } 186 | } 187 | 188 | async insert(items, options = {}) { 189 | 190 | if (!options.chunks || 191 | !Array.isArray(items) || 192 | items.length === 1) { 193 | 194 | return this._insert(items, options); 195 | } 196 | 197 | // Split items into batches 198 | 199 | const batches = []; 200 | let left = items; 201 | while (left.length) { 202 | batches.push(left.slice(0, options.chunks)); 203 | left = left.slice(options.chunks); 204 | } 205 | 206 | let result = []; 207 | 208 | for (let i = 0; i < batches.length; ++i) { 209 | const batch = batches[i]; 210 | 211 | const ids = await this._insert(batch, options); 212 | result = result.concat(ids); 213 | if (options.each) { 214 | await options.each(i, ids, this.name); 215 | } 216 | } 217 | 218 | return result; 219 | } 220 | 221 | async _insert(items, options) { 222 | 223 | const track = { table: this.name, action: 'insert', inputs: { items, options } }; 224 | 225 | try { 226 | const wrapped = await Id.wrap(this, this._write(items)); 227 | const postUnique = await Unique.reserve(this, wrapped, options.merge === true); 228 | 229 | const opt = { 230 | conflict: options.merge ? (options.merge === 'replace' ? 'replace' : 'update') : 'error', 231 | returnChanges: !!postUnique 232 | }; 233 | 234 | const result = await this._db._run(this.raw.insert(wrapped, opt), track); 235 | if (postUnique) { 236 | await postUnique(result.changes); 237 | } 238 | 239 | // Single item 240 | 241 | if (!Array.isArray(wrapped)) { 242 | return (wrapped[this.primary] !== undefined ? wrapped[this.primary] : result.generated_keys[0]); 243 | } 244 | 245 | // Items array 246 | 247 | const generated = result.generated_keys || []; 248 | if (generated.length === wrapped.length) { 249 | return result.generated_keys; 250 | } 251 | 252 | // Mixed array 253 | 254 | const ids = []; 255 | let g = 0; 256 | for (let i = 0; i < wrapped.length; ++i) { 257 | if (wrapped[i][this.primary] !== undefined) { 258 | ids.push(wrapped[i][this.primary]); 259 | } 260 | else { 261 | ids.push(result.generated_keys[g++]); 262 | } 263 | } 264 | 265 | return ids; 266 | } 267 | catch (err) { 268 | throw internals.Table.error(err, track); 269 | } 270 | } 271 | 272 | async update(ids, changes) { 273 | 274 | const track = { table: this.name, action: 'update', inputs: { ids, changes } }; 275 | 276 | // (id, changes) 277 | 278 | if (!Array.isArray(ids)) { 279 | Hoek.assert(changes && typeof changes === 'object', 'Invalid changes object'); 280 | return this._update(ids, changes, track); 281 | } 282 | 283 | if (!ids.length) { 284 | return; 285 | } 286 | 287 | // (updates) 288 | 289 | if (!changes) { 290 | const updates = ids; 291 | return this._update(Id.extract(updates, this), updates, track); 292 | } 293 | 294 | // ([ids], changes) 295 | 296 | if (Id.is(ids[0])) { 297 | Hoek.assert(!Array.isArray(changes), 'Changes cannot be an array when ids is an array'); 298 | return this._update(ids, changes, track); 299 | } 300 | 301 | // ([updates], options) 302 | 303 | const options = changes; 304 | changes = ids; 305 | 306 | if (!options.chunks || 307 | changes.length <= options.chunks) { 308 | 309 | return this._update(Id.extract(changes, this), changes, track); 310 | } 311 | 312 | // Split items into batches 313 | 314 | const batches = []; 315 | let left = changes; 316 | while (left.length) { 317 | batches.push(left.slice(0, options.chunks)); 318 | left = left.slice(options.chunks); 319 | } 320 | 321 | for (let i = 0; i < batches.length; ++i) { 322 | const batch = batches[i]; 323 | const bIds = Id.extract(batch, this); 324 | await this._update(bIds, batch, track); 325 | 326 | if (options.each) { 327 | await options.each(i, bIds, this.name); 328 | } 329 | } 330 | } 331 | 332 | static items(ids, changes) { 333 | 334 | // (id, changes) 335 | 336 | if (!Array.isArray(ids)) { 337 | return [changes]; 338 | } 339 | 340 | // ([ids], changes) 341 | 342 | if (Id.is(ids[0])) { 343 | return [changes]; 344 | } 345 | 346 | // ([updates]) 347 | // ([updates], options) 348 | 349 | return ids; 350 | } 351 | 352 | async _update(ids, changes, track) { 353 | 354 | try { 355 | const batch = Array.isArray(ids); 356 | ids = Id.normalize(ids, { allowArray: true }); 357 | 358 | const postUnique = await Unique.reserve(this, changes, (batch ? true : ids)); 359 | const wrapped = Modifier.wrap(changes, this); 360 | const opts = { returnChanges: !!postUnique }; 361 | const query = (batch ? this.raw.getAll(RethinkDB.args(ids)) : this.raw.get(ids)); 362 | const result = await this._db._run(query.replace(wrapped, opts), track); 363 | 364 | if (postUnique) { 365 | return postUnique(result.changes); 366 | } 367 | } 368 | catch (err) { 369 | throw internals.Table.error(err, track); 370 | } 371 | } 372 | 373 | async next(id, field, value) { 374 | 375 | const track = { table: this.name, action: 'next', inputs: { id, field, value } }; 376 | 377 | try { 378 | const changes = {}; 379 | changes[field] = RethinkDB.row(field).add(value); 380 | 381 | id = Id.normalize(id, { allowArray: false }); 382 | const result = await this._db._run(this.raw.get(id).update(changes, { returnChanges: true }), track); 383 | if (!result.replaced) { 384 | throw new Boom('No item found to update'); 385 | } 386 | 387 | const inc = result.changes[0].new_val[field]; 388 | return inc; 389 | } 390 | catch (err) { 391 | throw internals.Table.error(err, track); 392 | } 393 | } 394 | 395 | async remove(criteria) { 396 | 397 | const track = { table: this.name, action: 'remove', inputs: { criteria } }; 398 | 399 | try { 400 | const isBatch = Array.isArray(criteria); 401 | const isIds = (isBatch || typeof criteria !== 'object' || criteria[this.primary] !== undefined); 402 | if (isIds) { 403 | criteria = Id.normalize(criteria, { allowArray: true }); 404 | } 405 | 406 | const selection = (!isIds ? Criteria.select(criteria, this) 407 | : (isBatch ? this.raw.getAll(RethinkDB.args(criteria)) 408 | : this.raw.get(criteria))); 409 | 410 | const result = await this._db._run(selection.delete(), track); 411 | if (isIds && 412 | !isBatch && 413 | !result.deleted) { 414 | 415 | throw new Boom('No item found to remove'); 416 | } 417 | } 418 | catch (err) { 419 | throw internals.Table.error(err, track); 420 | } 421 | } 422 | 423 | async empty() { 424 | 425 | const track = { table: this.name, action: 'empty' }; 426 | 427 | try { 428 | const result = await this._db._run(this.raw.delete(), track); 429 | return result.deleted; 430 | } 431 | catch (err) { 432 | throw internals.Table.error(err, track); 433 | } 434 | } 435 | 436 | async sync() { 437 | 438 | const track = { table: this.name, action: 'sync' }; 439 | 440 | try { 441 | if (!this._db._connection) { 442 | throw new Boom('Database disconnected'); 443 | } 444 | 445 | await this.raw.sync().run(this._db._connection); 446 | } 447 | catch (err) { 448 | throw internals.Table.error(err, track); 449 | } 450 | } 451 | 452 | async index(indexes) { 453 | 454 | const track = { table: this.name, action: 'index' }; 455 | 456 | try { 457 | const pending = []; 458 | const names = []; 459 | indexes = [].concat(indexes); 460 | for (let i = 0; i < indexes.length; ++i) { 461 | let index = indexes[i]; 462 | if (typeof index === 'string') { 463 | index = { name: index }; 464 | } 465 | 466 | const { name, source, options } = index; 467 | names.push(name); 468 | 469 | const args = [name]; 470 | if (source) { 471 | args.push(Array.isArray(source) ? source.map((row) => RethinkDB.row(row)) : source); 472 | } 473 | 474 | args.push(options); 475 | pending.push(this._db._run(this.raw.indexCreate(...args), track)); 476 | } 477 | 478 | await Promise.all(pending); 479 | return this._db._run(this.raw.indexWait(RethinkDB.args(names)), track); 480 | } 481 | catch (err) { 482 | throw internals.Table.error(err, track); 483 | } 484 | } 485 | 486 | async changes(criteria, options) { 487 | 488 | const track = { table: this.name, action: 'changes', inputs: { criteria } }; 489 | 490 | if (!this._db._connection) { 491 | throw internals.Table.error(new Boom('Database disconnected'), track); 492 | } 493 | 494 | if (typeof options !== 'object') { 495 | options = { handler: options }; 496 | } 497 | 498 | Hoek.assert(typeof options.handler === 'function', 'Invalid options.handler handler'); 499 | 500 | let request = this.raw; 501 | if (criteria !== '*') { 502 | if (typeof criteria !== 'object') { 503 | request = this.raw.get(criteria); 504 | } 505 | else if (Array.isArray(criteria)) { 506 | request = this.raw.getAll(RethinkDB.args(criteria)); 507 | } 508 | else { 509 | request = this.raw.filter(criteria); 510 | } 511 | } 512 | 513 | const feedId = (options.reconnect !== false ? Id.uuid() : null); // Defaults to true 514 | if (feedId) { 515 | this._db._feeds[feedId] = { table: this, criteria, options }; // Keep original criteria reference to allow modifications during reconnection 516 | } 517 | 518 | const settings = { 519 | squash: options.squash === undefined ? true : options.squash, 520 | includeTypes: true, 521 | includeStates: false, 522 | includeInitial: options.initial || false 523 | }; 524 | 525 | let dbCursor; 526 | try { 527 | dbCursor = await request.changes().run(this._db._connection, settings); 528 | } 529 | catch (err) { 530 | throw internals.Table.error(err, track); 531 | } 532 | 533 | const cursor = new Cursor(dbCursor, this, feedId); 534 | const each = (item, next) => { 535 | 536 | const type = internals.changeTypes[item.type]; 537 | if (type === 'initial' && 538 | item.new_val === null) { 539 | 540 | return next(); // Initial result for missing id 541 | } 542 | 543 | const update = { 544 | id: item.old_val ? item.old_val[this.primary] : item.new_val[this.primary], 545 | type, 546 | before: this._read(item.old_val) || null, 547 | after: this._read(item.new_val) || null 548 | }; 549 | 550 | options.handler(null, update); 551 | return next(); 552 | }; 553 | 554 | dbCursor.eachAsync(each, (err) => { 555 | 556 | // Changes cursor ends only with an error 557 | 558 | if (err.msg === 'Cursor is closed.') { 559 | return; 560 | } 561 | 562 | const disconnected = (err.msg === 'Connection is closed.'); 563 | const willReconnect = (disconnected && feedId && this._db._willReconnect()) || false; 564 | cursor.close(false); 565 | 566 | if (feedId && 567 | !willReconnect) { 568 | 569 | delete this._db._feeds[feedId]; 570 | } 571 | 572 | return options.handler(internals.Table.error(err, track, { disconnected, willReconnect })); 573 | }); 574 | 575 | return cursor; 576 | } 577 | 578 | _read(items) { 579 | 580 | if (this._geo) { 581 | return Geo.read(items, this); 582 | } 583 | 584 | return items; 585 | } 586 | 587 | _write(items) { 588 | 589 | if (this._geo) { 590 | return Geo.write(items, this); 591 | } 592 | 593 | return items; 594 | } 595 | 596 | static error(err, { table, action, inputs }, flags) { 597 | 598 | const error = Boom.boomify(err); 599 | error.data = { table, action, inputs }; 600 | error.flags = flags; 601 | return error; 602 | } 603 | }; 604 | -------------------------------------------------------------------------------- /lib/unique.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('boom'); 4 | const Hoek = require('hoek'); 5 | 6 | const Special = require('./special'); 7 | 8 | 9 | const internals = {}; 10 | 11 | 12 | exports.compile = function (table, options) { 13 | 14 | if (!options) { 15 | return false; 16 | } 17 | 18 | Hoek.assert(table._id, `Cannot enforce uniqueness without local id allocation: ${table.name}`); 19 | 20 | const settings = { 21 | verified: false, 22 | tables: [], 23 | rules: [] 24 | }; 25 | 26 | options.forEach((field) => { 27 | 28 | const name = field.table || `penseur_unique_${table.name}_${field.path.join('_')}`; 29 | const unique = { 30 | path: field.path, 31 | table: table._db._generateTable(name), 32 | key: field.key 33 | }; 34 | 35 | settings.rules.push(unique); 36 | settings.tables.push(name); 37 | }); 38 | 39 | return settings; 40 | }; 41 | 42 | 43 | exports.reserve = async function (table, items, updateId) { 44 | 45 | if (!table._unique) { 46 | return; 47 | } 48 | 49 | // Find unique changes 50 | 51 | const reserve = []; 52 | const release = []; 53 | 54 | items = [].concat(items); 55 | for (let i = 0; i < items.length; ++i) { 56 | const item = items[i]; 57 | 58 | for (let j = 0; j < table._unique.rules.length; ++j) { 59 | const rule = table._unique.rules[j]; 60 | 61 | const values = internals.reach(item, rule.path); 62 | if (values !== undefined) { 63 | if (values.length) { 64 | reserve.push({ rule, values, id: typeof updateId !== 'boolean' ? updateId : item[table.primary] }); 65 | } 66 | 67 | if (updateId !== false && 68 | !values._bypass) { 69 | 70 | release.push(rule); 71 | } 72 | } 73 | } 74 | } 75 | 76 | // Prepare cleanup operation 77 | 78 | let cleanup = null; 79 | if (release.length) { 80 | cleanup = async (changes) => { 81 | 82 | const change = changes[0]; // Always includes one change 83 | for (let i = 0; i < release.length; ++i) { 84 | const rule = release[i]; 85 | 86 | if (!change.old_val) { 87 | continue; 88 | } 89 | 90 | let released = internals.reach(change.old_val, rule.path); 91 | if (!released) { 92 | continue; 93 | } 94 | 95 | const taken = internals.reach(change.new_val, rule.path); 96 | if (taken) { 97 | released = released.filter((value) => taken.indexOf(value) === -1); 98 | } 99 | 100 | if (!released.length) { 101 | continue; 102 | } 103 | 104 | await rule.table.remove(released); 105 | } 106 | }; 107 | } 108 | 109 | // Reserve new values 110 | 111 | if (!reserve.length) { 112 | return cleanup; 113 | } 114 | 115 | await exports.verify(table); 116 | 117 | for (let i = 0; i < reserve.length; ++i) { 118 | const field = reserve[i]; 119 | 120 | // Try to get existing reservations 121 | 122 | let values = field.values; 123 | const existing = await field.rule.table.get(values); 124 | 125 | if (existing) { 126 | const existingIds = []; 127 | for (let j = 0; j < existing.length; ++j) { 128 | const item = existing[j]; 129 | if (item[field.rule.key] !== field.id) { 130 | throw Boom.internal(`Action will violate unique restriction on ${item[table.primary]} in table ${field.rule.table.name}`); 131 | } 132 | 133 | existingIds.push(item[table.primary]); 134 | } 135 | 136 | values = values.filter((value) => existingIds.indexOf(value) === -1); 137 | } 138 | 139 | const reservations = []; 140 | const now = Date.now(); 141 | values.forEach((value) => { 142 | 143 | const rsv = { id: value, created: now }; 144 | rsv[field.rule.key] = field.id; 145 | reservations.push(rsv); 146 | }); 147 | 148 | await field.rule.table.insert(reservations); 149 | } 150 | 151 | return cleanup; 152 | }; 153 | 154 | 155 | internals.reach = function (obj, path) { 156 | 157 | // Optimize common cases 158 | 159 | let ref = undefined; 160 | if (path.length === 1) { 161 | ref = obj[path[0]]; 162 | } 163 | else if (path.length === 2) { 164 | ref = obj[path[0]] && obj[path[0]][path[1]]; 165 | } 166 | else { 167 | 168 | // Any longer path 169 | 170 | ref = obj; 171 | for (let i = 0; i < path.length; ++i) { 172 | const key = path[i]; 173 | 174 | if (ref[key] === undefined) { 175 | return undefined; 176 | } 177 | 178 | ref = ref[key]; 179 | } 180 | } 181 | 182 | // Normalize value 183 | 184 | if (ref === undefined) { 185 | return undefined; 186 | } 187 | 188 | if (Array.isArray(ref)) { 189 | return ref; 190 | } 191 | 192 | if (typeof ref === 'object' && 193 | !Special.isSpecial(ref)) { 194 | 195 | const keys = Object.keys(ref); 196 | let unset = false; 197 | const taken = keys.filter((key) => { 198 | 199 | if (!Special.isSpecial(ref[key])) { 200 | return true; 201 | } 202 | 203 | if (ref[key].type !== 'unset') { 204 | return true; 205 | } 206 | 207 | unset = true; 208 | return false; 209 | }); 210 | 211 | return taken.length ? taken : (unset ? [] : undefined); 212 | } 213 | 214 | if (Special.isSpecial(ref)) { 215 | if (ref.type === 'unset') { 216 | return []; 217 | } 218 | 219 | if (ref.type === 'append') { 220 | if (Array.isArray(ref.value) && 221 | ref.flags.single) { 222 | 223 | throw Boom.internal('Cannot add an array as single value to unique index value'); 224 | } 225 | 226 | const result = Array.isArray(ref.value) ? ref.value : [ref.value]; 227 | result._bypass = true; 228 | return result; 229 | } 230 | 231 | throw Boom.internal('Cannot increment unique index value'); // type: increment 232 | } 233 | 234 | return [ref]; 235 | }; 236 | 237 | 238 | exports.verify = async function (table) { 239 | 240 | if (!table._unique || 241 | table._unique.verified) { 242 | 243 | return; 244 | } 245 | 246 | for (let i = 0; i < table._unique.tables.length; ++i) { 247 | const name = table._unique.tables[i]; 248 | const create = {}; 249 | create[name] = { purge: false, secondary: false }; 250 | try { 251 | await table._db._createTable(create); 252 | } 253 | catch (err) { 254 | err.message = `Failed creating unique table: ${name}`; 255 | throw err; 256 | } 257 | } 258 | 259 | table._unique.verified = true; 260 | }; 261 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('hoek'); 4 | 5 | 6 | const internals = {}; 7 | 8 | 9 | exports.diff = function (base, compare, options) { 10 | 11 | options = options || {}; 12 | 13 | if (!base) { 14 | if (!options.whitelist) { 15 | return compare; 16 | } 17 | 18 | const picked = internals.pick(options.whitelist, compare); 19 | return (Object.keys(picked).length ? picked : null); 20 | } 21 | 22 | let keys = options.whitelist || Object.keys(compare); 23 | if (options.deleted && 24 | !options.whitelist) { 25 | 26 | keys = [...new Set(keys.concat(Object.keys(base)))]; // Unique keys 27 | } 28 | 29 | const changes = {}; 30 | for (const key of keys) { 31 | const value = compare[key]; 32 | if (value === undefined) { // Monitored key not present 33 | if (base[key] !== undefined && 34 | options.deleted) { 35 | 36 | changes[key] = null; 37 | } 38 | 39 | continue; 40 | } 41 | 42 | if (value !== null && 43 | base[key] !== null && 44 | typeof value === 'object' && 45 | typeof base[key] === 'object') { 46 | 47 | if (Array.isArray(value) || 48 | Array.isArray(base[key])) { 49 | 50 | if (Hoek.deepEqual(base[key], value)) { 51 | continue; 52 | } 53 | 54 | if (options.arrays === false || // Defaults to true 55 | !Array.isArray(value) || 56 | !Array.isArray(base[key])) { 57 | 58 | changes[key] = value; 59 | continue; 60 | } 61 | } 62 | 63 | const change = exports.diff(base[key], value, { arrays: options.arrays, deleted: options.deleted }); 64 | if (change) { 65 | changes[key] = change; 66 | } 67 | 68 | continue; 69 | } 70 | 71 | if (value !== base[key]) { 72 | changes[key] = value; 73 | } 74 | } 75 | 76 | if (!Object.keys(changes).length) { 77 | return null; 78 | } 79 | 80 | return changes; 81 | }; 82 | 83 | 84 | internals.pick = function (keys, source) { 85 | 86 | const result = {}; 87 | keys.forEach((key) => { 88 | 89 | if (source[key] !== undefined) { 90 | result[key] = source[key]; 91 | } 92 | }); 93 | 94 | return result; 95 | }; 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "penseur", 3 | "description": "Lightweight RethinkDB wrapper", 4 | "version": "8.12.3", 5 | "repository": "git://github.com/hueniverse/penseur", 6 | "main": "lib/index.js", 7 | "keywords": [ 8 | "rethinkdb" 9 | ], 10 | "dependencies": { 11 | "boom": "7.x.x", 12 | "bounce": "1.x.x", 13 | "hoek": "6.x.x", 14 | "joi": "14.x.x", 15 | "radix62": "1.x.x", 16 | "rethinkdb": "2.3.x" 17 | }, 18 | "devDependencies": { 19 | "code": "5.x.x", 20 | "lab": "18.x.x", 21 | "teamwork": "3.x.x" 22 | }, 23 | "scripts": { 24 | "test": "lab -a code -t 100 -L -m 10000", 25 | "test-cov-html": "lab -a code -r html -o coverage.html -m 10000" 26 | }, 27 | "license": "BSD-3-Clause" 28 | } 29 | -------------------------------------------------------------------------------- /test/criteria.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Lab = require('lab'); 5 | const Penseur = require('..'); 6 | 7 | 8 | const internals = {}; 9 | 10 | 11 | const { describe, it } = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | describe('Criteria', () => { 16 | 17 | it('parses empty criteria', async () => { 18 | 19 | const db = new Penseur.Db('penseurtest'); 20 | await db.establish(['test']); 21 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 1 }]); 22 | const result = await db.test.query({}); 23 | expect(result.length).to.equal(3); 24 | }); 25 | 26 | it('parses multiple keys', async () => { 27 | 28 | const db = new Penseur.Db('penseurtest'); 29 | await db.establish(['test']); 30 | await db.test.insert([{ id: 1, a: 1, b: 2 }, { id: 2, a: 2, b: 1 }, { id: 3, a: 1, b: 1 }]); 31 | const result = await db.test.query({ a: 1, b: 1 }); 32 | expect(result).to.equal([{ id: 3, a: 1, b: 1 }]); 33 | }); 34 | 35 | it('parses nested keys', async () => { 36 | 37 | const db = new Penseur.Db('penseurtest'); 38 | await db.establish(['test']); 39 | await db.test.insert([{ id: 1, a: 1, b: { c: 2 } }, { id: 2, a: 2, b: { c: 1 } }, { id: 3, a: 1, b: { c: 1 } }]); 40 | const result = await db.test.query({ a: 1, b: { c: 1 } }); 41 | expect(result).to.equal([{ id: 3, a: 1, b: { c: 1 } }]); 42 | }); 43 | 44 | it('parses or', async () => { 45 | 46 | const db = new Penseur.Db('penseurtest'); 47 | await db.establish(['test']); 48 | await db.test.insert([{ id: 1, a: 1, b: { c: 2 } }, { id: 2, a: 2, b: { c: 1 } }, { id: 3, a: 1, b: { c: 1 } }]); 49 | const result = await db.test.query({ a: 1, b: { c: db.or([1, 2]) } }); 50 | expect(result).to.equal([{ id: 3, a: 1, b: { c: 1 } }, { id: 1, a: 1, b: { c: 2 } }]); 51 | }); 52 | 53 | it('parses or with comparator', async () => { 54 | 55 | const db = new Penseur.Db('penseurtest'); 56 | await db.establish(['test']); 57 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 3 }]); 58 | const result = await db.test.query({ a: db.or([db.is('>=', 3), db.is('eq', 1)]) }); 59 | expect(result).to.equal([{ id: 3, a: 3 }, { id: 1, a: 1 }]); 60 | }); 61 | 62 | it('parses or unset', async () => { 63 | 64 | const db = new Penseur.Db('penseurtest'); 65 | await db.establish(['test']); 66 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, b: 1 }]); 67 | const result = await db.test.query({ a: db.or([2, db.unset()]) }); 68 | expect(result).to.equal([{ id: 3, b: 1 }, { id: 2, a: 2 }]); 69 | }); 70 | 71 | it('parses or unset nested', async () => { 72 | 73 | const db = new Penseur.Db('penseurtest'); 74 | await db.establish(['test']); 75 | await db.test.insert([{ id: 1, a: 1, b: { c: 2 } }, { id: 2, a: 1, b: { d: 3 } }, { id: 3, a: 1, b: { c: 66 } }]); 76 | const result = await db.test.query({ a: 1, b: { c: db.or([66, db.unset()]) } }); 77 | expect(result).to.equal([{ id: 3, a: 1, b: { c: 66 } }, { id: 2, a: 1, b: { d: 3 } }]); 78 | }); 79 | 80 | it('parses not unset nested', async () => { 81 | 82 | const db = new Penseur.Db('penseurtest'); 83 | await db.establish(['test']); 84 | await db.test.insert([{ id: 1, a: 1, b: { c: 2 } }, { id: 2, a: 1, b: { d: 3 } }, { id: 3, a: 1, b: { c: 66 } }]); 85 | const result = await db.test.query({ a: 1, b: { c: db.not([66, db.unset()]) } }); 86 | expect(result).to.equal([{ id: 1, a: 1, b: { c: 2 } }]); 87 | }); 88 | 89 | it('parses or empty', async () => { 90 | 91 | const db = new Penseur.Db('penseurtest'); 92 | await db.establish(['test']); 93 | await db.test.insert([{ id: 1, a: [] }, { id: 2, a: 1 }, { id: 3, a: [2] }]); 94 | const result = await db.test.query({ a: db.or([1, db.empty()]) }); 95 | expect(result).to.equal([{ id: 2, a: 1 }, { id: 1, a: [] }]); 96 | }); 97 | 98 | it('parses or empty nested', async () => { 99 | 100 | const db = new Penseur.Db('penseurtest'); 101 | await db.establish(['test']); 102 | await db.test.insert([{ id: 1, a: 1, b: { c: [1] } }, { id: 2, a: 1, b: { c: [] } }, { id: 3, a: 1, b: { c: 66 } }]); 103 | const result = await db.test.query({ a: 1, b: { c: db.or([66, db.empty()]) } }); 104 | expect(result).to.equal([{ id: 3, a: 1, b: { c: 66 } }, { id: 2, a: 1, b: { c: [] } }]); 105 | }); 106 | 107 | it('parses not empty nested', async () => { 108 | 109 | const db = new Penseur.Db('penseurtest'); 110 | await db.establish(['test']); 111 | await db.test.insert([{ id: 1, a: 1, b: { c: [1] } }, { id: 2, a: 1, b: { c: [] } }, { id: 3, a: 1, b: { c: 66 } }]); 112 | const result = await db.test.query({ a: 1, b: { c: db.not([66, db.empty()]) } }); 113 | expect(result).to.equal([{ id: 1, a: 1, b: { c: [1] } }]); 114 | }); 115 | 116 | it('parses or root', async () => { 117 | 118 | const db = new Penseur.Db('penseurtest'); 119 | await db.establish(['test']); 120 | 121 | await db.test.insert([ 122 | { id: 1, a: 1, b: { c: 2 } }, 123 | { id: 2, a: 2, b: { c: 1 } }, 124 | { id: 3, a: 3, b: { c: 3 } }, 125 | { id: 4, a: 1 } 126 | ]); 127 | 128 | const result = await db.test.query(db.or([{ a: 1 }, { b: { c: 3 } }])); 129 | expect(result).to.equal([{ id: 4, a: 1 }, { id: 3, a: 3, b: { c: 3 } }, { id: 1, a: 1, b: { c: 2 } }]); 130 | }); 131 | 132 | it('parses or objects', async () => { 133 | 134 | const db = new Penseur.Db('penseurtest'); 135 | await db.establish(['test']); 136 | 137 | await db.test.insert([ 138 | { id: 1, x: { a: 1, b: { c: 2 } } }, 139 | { id: 2, x: { a: 2, b: { c: 1 } } }, 140 | { id: 3, x: { a: 3, b: { c: 3 } } }, 141 | { id: 4, x: { a: 1 } } 142 | ]); 143 | 144 | const result = await db.test.query({ x: db.or([{ a: 1 }, { b: { c: 3 } }]) }); 145 | expect(result).to.equal([{ id: 4, x: { a: 1 } }, { id: 3, x: { a: 3, b: { c: 3 } } }, { id: 1, x: { a: 1, b: { c: 2 } } }]); 146 | }); 147 | 148 | it('parses is', async () => { 149 | 150 | const db = new Penseur.Db('penseurtest'); 151 | await db.establish(['test']); 152 | 153 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 3 }]); 154 | const result = await db.test.query({ a: db.is('<', 2) }); 155 | expect(result).to.equal([{ id: 1, a: 1 }]); 156 | }); 157 | 158 | it('parses is (multiple conditions)', async () => { 159 | 160 | const db = new Penseur.Db('penseurtest'); 161 | await db.establish(['test']); 162 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 3 }]); 163 | const result = await db.test.query({ a: db.is('>', 1, '<', 3) }); 164 | expect(result).to.equal([{ id: 2, a: 2 }]); 165 | }); 166 | 167 | it('parses contains', async () => { 168 | 169 | const db = new Penseur.Db('penseurtest'); 170 | await db.establish(['test']); 171 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { c: [3, 4] } }, { id: 3, a: 1, b: { c: [2, 3] } }]); 172 | const result = await db.test.query({ a: 1, b: { c: db.contains(1) } }); 173 | expect(result).to.equal([{ id: 1, a: 1, b: { c: [1, 2] } }]); 174 | }); 175 | 176 | it('parses contains or', async () => { 177 | 178 | const db = new Penseur.Db('penseurtest'); 179 | await db.establish(['test']); 180 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { c: [3, 4] } }, { id: 3, a: 1, b: { c: [2, 3] } }]); 181 | const result = await db.test.query({ a: 1, b: { c: db.contains([1, 2], { condition: 'or' }) } }); 182 | expect(result).to.equal([{ id: 3, a: 1, b: { c: [2, 3] } }, { id: 1, a: 1, b: { c: [1, 2] } }]); 183 | }); 184 | 185 | it('parses contains and', async () => { 186 | 187 | const db = new Penseur.Db('penseurtest'); 188 | await db.establish(['test']); 189 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { c: [3, 4] } }, { id: 3, a: 1, b: { c: [2, 3] } }]); 190 | const result = await db.test.query({ a: 1, b: { c: db.contains([1, 2], { condition: 'and' }) } }); 191 | expect(result).to.equal([{ id: 1, a: 1, b: { c: [1, 2] } }]); 192 | }); 193 | 194 | it('parses contains and default', async () => { 195 | 196 | const db = new Penseur.Db('penseurtest'); 197 | await db.establish(['test']); 198 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { c: [3, 4] } }, { id: 3, a: 1, b: { c: [2, 3] } }]); 199 | const result = await db.test.query({ a: 1, b: { c: db.contains([1, 2]) } }); 200 | expect(result).to.equal([{ id: 1, a: 1, b: { c: [1, 2] } }]); 201 | }); 202 | 203 | it('parses contains key', async () => { 204 | 205 | const db = new Penseur.Db('penseurtest'); 206 | await db.establish(['test']); 207 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { d: [3, 4] } }, { id: 3, a: 1, b: { e: [2, 3] } }]); 208 | const result = await db.test.query({ a: 1, b: db.contains('c', { keys: true }) }); 209 | expect(result).to.equal([{ id: 1, a: 1, b: { c: [1, 2] } }]); 210 | }); 211 | 212 | it('parses contains keys or', async () => { 213 | 214 | const db = new Penseur.Db('penseurtest'); 215 | await db.establish(['test']); 216 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { d: [3, 4] } }, { id: 3, a: 1, b: { e: [2, 3] } }]); 217 | const result = await db.test.query({ a: 1, b: db.contains(['c', 'e'], { keys: true, condition: 'or' }) }); 218 | expect(result).to.equal([{ id: 3, a: 1, b: { e: [2, 3] } }, { id: 1, a: 1, b: { c: [1, 2] } }]); 219 | }); 220 | 221 | it('parses contains keys and', async () => { 222 | 223 | const db = new Penseur.Db('penseurtest'); 224 | await db.establish(['test']); 225 | await db.test.insert([{ id: 1, a: 1, b: { c: [1, 2] } }, { id: 2, a: 2, b: { d: [3, 4] } }, { id: 3, a: 1, b: { e: [2, 3], f: 'x' } }]); 226 | const result = await db.test.query({ a: 1, b: db.contains(['f', 'e'], { keys: true, condition: 'and' }) }); 227 | expect(result).to.equal([{ id: 3, a: 1, b: { e: [2, 3], f: 'x' } }]); 228 | }); 229 | 230 | it('parses unset key', async () => { 231 | 232 | const db = new Penseur.Db('penseurtest'); 233 | await db.establish(['test']); 234 | await db.test.insert([{ id: 1, a: 1, b: { c: 2 } }, { id: 2, a: 1, b: { d: 3 } }, { id: 3, a: 1, b: { c: null } }]); 235 | const result = await db.test.query({ a: 1, b: { c: db.unset() } }); 236 | expect(result).to.equal([{ id: 3, a: 1, b: { c: null } }, { id: 2, a: 1, b: { d: 3 } }]); 237 | }); 238 | 239 | it('parses empty key', async () => { 240 | 241 | const db = new Penseur.Db('penseurtest'); 242 | await db.establish(['test']); 243 | await db.test.insert([{ id: 1, a: 1, b: null }, { id: 2, a: 1, b: [2] }, { id: 3, a: 2, b: [] }, { id: 4, a: 1, b: 3 }, { id: 5, a: 1, b: { } }]); 244 | const result = await db.test.query({ a: 1, b: db.empty() }); 245 | expect(result).to.equal([{ id: 5, a: 1, b: { } }]); 246 | }); 247 | 248 | it('parses nested empty key for arrays', async () => { 249 | 250 | const db = new Penseur.Db('penseurtest'); 251 | await db.establish(['test']); 252 | await db.test.insert([{ id: 1, a: 1, b: { c: null } }, { id: 2, a: 1, b: { c: [2] } }, { id: 3, a: 1, b: { c: [] } }, { id: 4, a: 1, b: { c: 3 } }]); 253 | const result = await db.test.query({ a: 1, b: { c: db.empty() } }); 254 | expect(result).to.equal([{ id: 3, a: 1, b: { c: [] } }]); 255 | }); 256 | 257 | it('parses nested empty key for objects', async () => { 258 | 259 | const db = new Penseur.Db('penseurtest'); 260 | await db.establish(['test']); 261 | await db.test.insert([{ id: 1, a: 1, b: { c: null } }, { id: 2, a: 1, b: { c: { d: 4 } } }, { id: 3, a: 1, b: { c: {} } }, { id: 4, a: 1, b: { c: 3 } }]); 262 | const result = await db.test.query({ a: 1, b: { c: db.empty() } }); 263 | expect(result).to.equal([{ id: 3, a: 1, b: { c: {} } }]); 264 | }); 265 | 266 | it('handles query into nested object where item is not an object', async () => { 267 | 268 | const db = new Penseur.Db('penseurtest'); 269 | await db.establish(['test']); 270 | await db.test.insert([{ id: 1, a: 1, b: false }, { id: 2, a: 2, b: { c: 1 } }, { id: 3, a: 1, b: { c: 1 } }]); 271 | const result = await db.test.query({ a: 1, b: { c: 1 } }); 272 | expect(result).to.equal([{ id: 3, a: 1, b: { c: 1 } }]); 273 | }); 274 | 275 | it('handles query into double nested object where item is not an object', async () => { 276 | 277 | const db = new Penseur.Db('penseurtest'); 278 | await db.establish(['test']); 279 | 280 | await db.test.insert([ 281 | { id: 1, a: 1, b: false }, 282 | { id: 2, a: 2, b: { c: { d: 4 } } }, 283 | { id: 3, a: 1, b: { c: 1 } } 284 | ]); 285 | 286 | const result = await db.test.query({ b: { c: { d: 4 } } }); 287 | expect(result).to.equal([{ id: 2, a: 2, b: { c: { d: 4 } } }]); 288 | }); 289 | 290 | it('parses matches', async () => { 291 | 292 | const db = new Penseur.Db('penseurtest'); 293 | await db.establish(['test']); 294 | await db.test.insert([{ id: 1, a: 'abcdef' }, { id: 2, a: 'acdef' }, { id: 3, a: 'bcdef' }, { id: 4, a: 'cdefg' }, { id: 5, a: 'ABC' }]); 295 | expect(await db.test.query({ a: db.match('b') })).to.equal([{ id: 3, a: 'bcdef' }, { id: 1, a: 'abcdef' }]); 296 | expect(await db.test.query({ a: db.match(['a', 'b']) })).to.equal([{ id: 1, a: 'abcdef' }]); 297 | expect(await db.test.query({ a: db.match(['a', 'b'], { condition: 'or' }) })).to.equal([{ id: 3, a: 'bcdef' }, { id: 2, a: 'acdef' }, { id: 1, a: 'abcdef' }]); 298 | expect(await db.test.query({ a: db.match('b', { start: true }) })).to.equal([{ id: 3, a: 'bcdef' }]); 299 | expect(await db.test.query({ a: db.match('G', { end: true, insensitive: true }) })).to.equal([{ id: 4, a: 'cdefg' }]); 300 | expect(await db.test.query({ a: db.match('abc', { exact: true, insensitive: true }) })).to.equal([{ id: 5, a: 'ABC' }]); 301 | }); 302 | 303 | describe('select()', () => { 304 | 305 | it('selects by secondary index', async () => { 306 | 307 | const db = new Penseur.Db('penseurtest'); 308 | await db.establish({ test: { secondary: 'a' } }); 309 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 1 }]); 310 | const result = await db.test.query(db.by('a', 1)); 311 | expect(result).to.equal([{ id: 3, a: 1 }, { id: 1, a: 1 }]); 312 | }); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /test/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Hoek = require('hoek'); 5 | const Lab = require('lab'); 6 | const Penseur = require('..'); 7 | const RethinkDB = require('rethinkdb'); 8 | const Teamwork = require('teamwork'); 9 | 10 | 11 | const internals = {}; 12 | 13 | 14 | const { describe, it } = exports.lab = Lab.script(); 15 | const expect = Code.expect; 16 | 17 | 18 | describe('Db', () => { 19 | 20 | it('exposes driver', () => { 21 | 22 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 23 | expect(db.r).to.shallow.equal(RethinkDB); 24 | expect(Penseur.r).to.shallow.equal(RethinkDB); 25 | }); 26 | 27 | it('establishes and interacts with a database', async () => { 28 | 29 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 30 | 31 | await db.establish(['test']); 32 | await db.test.insert({ id: 1, value: 'x' }); 33 | const item = await db.test.get(1); 34 | expect(item.value).to.equal('x'); 35 | await db.close(); 36 | }); 37 | 38 | it('overrides table methods', async () => { 39 | 40 | const Override = class extends Penseur.Table { 41 | 42 | insert(items) { 43 | 44 | items = [].concat(items); 45 | for (let i = 0; i < items.length; ++i) { 46 | items[i].flag = true; 47 | } 48 | 49 | return super.insert(items); 50 | } 51 | }; 52 | 53 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015, extended: Override }); 54 | 55 | await db.establish(['test']); 56 | await db.test.insert({ id: 1, value: 'x' }); 57 | const item = await db.test.get(1); 58 | expect(item.value).to.equal('x'); 59 | expect(item.flag).to.equal(true); 60 | await db.close(); 61 | }); 62 | 63 | it('decorates static methods', () => { 64 | 65 | expect(Penseur.Db.or).to.exist(); 66 | expect(Penseur.Db.contains).to.exist(); 67 | expect(Penseur.Db.not).to.exist(); 68 | expect(Penseur.Db.unset).to.exist(); 69 | expect(Penseur.Db.append).to.exist(); 70 | expect(Penseur.Db.increment).to.exist(); 71 | }); 72 | 73 | describe('connect()', () => { 74 | 75 | it('uses default server location', async () => { 76 | 77 | const db = new Penseur.Db('penseurtest'); 78 | await db.connect(); 79 | await db.close(); 80 | }); 81 | 82 | it('fails connecting to missing server', async () => { 83 | 84 | const db = new Penseur.Db('penseurtest', { host: 'example.com', timeout: 0.001 }); 85 | await expect(db.connect()).to.reject(); 86 | await db.close(); 87 | }); 88 | 89 | it('reconnects automatically', async () => { 90 | 91 | const team = new Teamwork({ meetings: 2 }); 92 | let count = 0; 93 | const onConnect = () => { 94 | 95 | ++count; 96 | team.attend(); 97 | }; 98 | 99 | const onDisconnect = (willReconnect) => { 100 | 101 | expect(willReconnect).to.equal(count !== 2); 102 | }; 103 | 104 | const db = new Penseur.Db('penseurtest', { onDisconnect, onConnect }); 105 | 106 | await db.connect(); 107 | db._connection.close(); 108 | await team.work; 109 | await db.close(); 110 | }); 111 | 112 | it('reports reconnect errors and tries again', async () => { 113 | 114 | const team = new Teamwork({ meetings: 2 }); 115 | 116 | let orig = null; 117 | let count = 0; 118 | const onConnect = () => { 119 | 120 | ++count; 121 | team.attend(); 122 | }; 123 | 124 | const onDisconnect = (willReconnect) => { 125 | 126 | expect(willReconnect).to.equal(count !== 2); 127 | }; 128 | 129 | const onError = (err) => { 130 | 131 | expect(err).to.exist(); 132 | db.connect = orig; 133 | }; 134 | 135 | const db = new Penseur.Db('penseurtest', { onDisconnect, onConnect, onError }); 136 | orig = db.connect; 137 | 138 | await db.connect(); 139 | db.connect = () => Promise.reject(new Error('failed to connect')); 140 | db._connection.close(Hoek.ignore); 141 | await team.work; 142 | await db.close(); 143 | }); 144 | 145 | it('waits between reconnections', async () => { 146 | 147 | const team = new Teamwork({ meetings: 2 }); 148 | const timer = new Hoek.Bench(); 149 | 150 | let orig = null; 151 | let count = 0; 152 | const onConnect = () => { 153 | 154 | ++count; 155 | team.attend(); 156 | }; 157 | 158 | const onDisconnect = (willReconnect) => { 159 | 160 | expect(willReconnect).to.equal(count !== 2); 161 | }; 162 | 163 | let errors = 0; 164 | const onError = (err) => { 165 | 166 | ++errors; 167 | expect(err).to.exist(); 168 | if (errors === 2) { 169 | db.connect = orig; 170 | } 171 | }; 172 | 173 | const db = new Penseur.Db('penseurtest', { onDisconnect, onConnect, onError, reconnectTimeout: 100 }); 174 | orig = db.connect; 175 | 176 | await db.connect(); 177 | db.connect = () => Promise.reject(new Error('failed to connect')); 178 | timer.reset(); 179 | db._connection.close(Hoek.ignore); 180 | await team.work; 181 | expect(timer.elapsed()).to.be.above(200); 182 | await db.close(); 183 | }); 184 | 185 | it('reconnects immediately', async () => { 186 | 187 | const team = new Teamwork({ meetings: 2 }); 188 | const timer = new Hoek.Bench(); 189 | 190 | let orig = null; 191 | let count = 0; 192 | const onConnect = () => { 193 | 194 | ++count; 195 | team.attend(); 196 | }; 197 | 198 | const onDisconnect = (willReconnect) => { 199 | 200 | expect(willReconnect).to.equal(count !== 2); 201 | }; 202 | 203 | let errors = 0; 204 | const onError = (err) => { 205 | 206 | ++errors; 207 | expect(err).to.exist(); 208 | if (errors === 2) { 209 | db.connect = orig; 210 | } 211 | }; 212 | 213 | const db = new Penseur.Db('penseurtest', { onDisconnect, onConnect, onError, reconnectTimeout: false }); 214 | orig = db.connect; 215 | 216 | await db.connect(); 217 | db.connect = () => Promise.reject(new Error('failed to connect')); 218 | timer.reset(); 219 | db._connection.close(Hoek.ignore); 220 | await team.work; 221 | expect(timer.elapsed()).to.be.below(100); 222 | await db.close(); 223 | }); 224 | 225 | it('does not reconnect automatically', async () => { 226 | 227 | const team = new Teamwork({ meetings: 1 }); 228 | const onDisconnect = (willReconnect) => { 229 | 230 | expect(willReconnect).to.be.false(); 231 | team.attend(); 232 | }; 233 | 234 | const db = new Penseur.Db('penseurtest', { onDisconnect, reconnect: false }); 235 | 236 | await db.connect(); 237 | db._connection.close(Hoek.ignore); 238 | await team.work; 239 | await db.close(); 240 | }); 241 | 242 | it('notifies of errors', async () => { 243 | 244 | const team = new Teamwork({ meetings: 1 }); 245 | const onError = (err) => { 246 | 247 | expect(err.message).to.equal('boom'); 248 | team.attend(); 249 | }; 250 | 251 | const db = new Penseur.Db('penseurtest', { onError }); 252 | 253 | await db.connect(); 254 | db._connection.emit('error', new Error('boom')); 255 | await team.work; 256 | await db.close(); 257 | }); 258 | 259 | it('notifies of timeout', async () => { 260 | 261 | const team = new Teamwork({ meetings: 1 }); 262 | const onError = (err) => { 263 | 264 | expect(err.message).to.equal('Database connection timeout'); 265 | team.attend(); 266 | }; 267 | 268 | const db = new Penseur.Db('penseurtest', { onError }); 269 | await db.connect(); 270 | db._connection.emit('timeout'); 271 | await team.work; 272 | await db.close(); 273 | }); 274 | 275 | it('prepares generate id table', async () => { 276 | 277 | const prep = new Penseur.Db('penseurtest'); 278 | await prep.establish(['allocate', 'test']); // Cleanup 279 | await prep.close(); 280 | 281 | const db = new Penseur.Db('penseurtest'); 282 | db.table('test', { id: { type: 'increment', table: 'allocate' } }); 283 | await db.connect(); 284 | const keys = await db.test.insert({ a: 1 }); 285 | expect(keys).to.equal('1'); 286 | }); 287 | 288 | it('errors on missing database', async () => { 289 | 290 | const db = new Penseur.Db('penseurtest_no_such_db'); 291 | await expect(db.connect()).to.reject('Missing database: penseurtest_no_such_db'); 292 | await db.close(); 293 | }); 294 | 295 | it('errors on database dbList() error', async () => { 296 | 297 | const db = new Penseur.Db('penseurtest'); 298 | const orig = RethinkDB.dbList; 299 | RethinkDB.dbList = function () { 300 | 301 | RethinkDB.dbList = orig; 302 | 303 | return { 304 | run: function (connection) { 305 | 306 | return Promise.reject(new Error('Bad database')); 307 | } 308 | }; 309 | }; 310 | 311 | await expect(db.connect()).to.reject(); 312 | await db.close(); 313 | }); 314 | }); 315 | 316 | describe('close()', () => { 317 | 318 | it('ignores unconnected state', async () => { 319 | 320 | const db = new Penseur.Db('penseurtest'); 321 | await db.close(); 322 | }); 323 | }); 324 | 325 | describe('table()', () => { 326 | 327 | it('skips decorating object when table name conflicts', async () => { 328 | 329 | const db = new Penseur.Db('penseurtest'); 330 | await db.establish(['connect']); 331 | expect(typeof db.connect).to.equal('function'); 332 | expect(db.tables.connect).to.exist(); 333 | await db.close(); 334 | }); 335 | 336 | it('skips decorating object when table name begins with _', async () => { 337 | 338 | const db = new Penseur.Db('penseurtest'); 339 | await db.establish(['_testx']); 340 | expect(db._testx).to.not.exist(); 341 | expect(db.tables._testx).to.exist(); 342 | await db.close(); 343 | }); 344 | 345 | it('skips decorating object when table already set up', async () => { 346 | 347 | const db = new Penseur.Db('penseurtest'); 348 | 349 | db.table('abc'); 350 | await db.establish(['abc']); 351 | expect(db.tables.abc).to.exist(); 352 | await db.close(); 353 | }); 354 | 355 | it('decorates an array of tables', async () => { 356 | 357 | const db = new Penseur.Db('penseurtest'); 358 | db.table(['test1', 'test2']); 359 | expect(db.tables.test1).to.exist(); 360 | expect(db.tables.test2).to.exist(); 361 | await db.close(); 362 | }); 363 | }); 364 | 365 | describe('establish()', () => { 366 | 367 | it('creates new database', async () => { 368 | 369 | const db = new Penseur.Db('penseurtest'); 370 | await db.connect(); 371 | await RethinkDB.dbDrop(db.name).run(db._connection); 372 | 373 | await db.establish({ test: { secondary: 'other' } }); 374 | const result = await RethinkDB.db(db.name).table('test').indexList().run(db._connection); 375 | expect(result).to.equal(['other']); 376 | await db.close(); 377 | }); 378 | 379 | it('creates new database (implicit tables)', async () => { 380 | 381 | const db = new Penseur.Db('penseurtest'); 382 | await db.connect(); 383 | db.table({ test: { secondary: 'other' } }); 384 | 385 | await db.establish(); 386 | const result = await RethinkDB.db(db.name).table('test').indexList().run(db._connection); 387 | expect(result).to.equal(['other']); 388 | await db.close(); 389 | }); 390 | 391 | it('creates new database (complex tables pre-loaded)', async () => { 392 | 393 | const prep = new Penseur.Db('penseurtest'); 394 | await prep.connect(); 395 | await RethinkDB.dbDrop('penseurtest').run(prep._connection); 396 | await prep.close(); 397 | 398 | const db = new Penseur.Db('penseurtest'); 399 | db.table({ test: { id: 'increment' } }); 400 | 401 | await db.establish(['test']); 402 | await db.close(); 403 | }); 404 | 405 | it('customizes table options', async () => { 406 | 407 | const Override = class extends Penseur.Table { 408 | 409 | insert(items) { 410 | 411 | items = [].concat(items); 412 | for (let i = 0; i < items.length; ++i) { 413 | items[i].flag = true; 414 | } 415 | 416 | return super.insert(items); 417 | } 418 | }; 419 | 420 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 421 | 422 | await db.establish({ test: { extended: Override }, user: false, other: true }); 423 | expect(db.user).to.not.exist(); 424 | expect(db.other).to.exist(); 425 | 426 | await db.test.insert({ id: 1, value: 'x' }); 427 | const item = await db.test.get(1); 428 | expect(item.value).to.equal('x'); 429 | expect(item.flag).to.equal(true); 430 | await db.close(); 431 | }); 432 | 433 | it('customizes table options before establish', async () => { 434 | 435 | const Override = class extends Penseur.Table { 436 | 437 | insert(items) { 438 | 439 | items = [].concat(items); 440 | for (let i = 0; i < items.length; ++i) { 441 | items[i].flag = true; 442 | } 443 | 444 | return super.insert(items); 445 | } 446 | }; 447 | 448 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 449 | db.table('test', { extended: Override }); 450 | await db.establish('test'); 451 | 452 | await db.test.insert({ id: 1, value: 'x' }); 453 | const item = await db.test.get(1); 454 | expect(item.value).to.equal('x'); 455 | expect(item.flag).to.equal(true); 456 | await db.close(); 457 | }); 458 | 459 | it('creates database with different table indexes', async () => { 460 | 461 | const db1 = new Penseur.Db('penseurtest'); 462 | await db1.connect(); 463 | await db1.establish({ test: { secondary: 'other' } }); 464 | const result1 = await RethinkDB.db(db1.name).table('test').indexList().run(db1._connection); 465 | expect(result1).to.equal(['other']); 466 | await db1.close(); 467 | 468 | const db2 = new Penseur.Db('penseurtest'); 469 | await db2.connect(); 470 | await db2.establish(['test']); 471 | const result2 = await RethinkDB.db(db2.name).table('test').indexList().run(db2._connection); 472 | expect(result2).to.equal([]); 473 | await db2.close(); 474 | }); 475 | 476 | it('creates database and retains table indexes', async () => { 477 | 478 | const db1 = new Penseur.Db('penseurtest'); 479 | await db1.connect(); 480 | await db1.establish({ test: { secondary: 'other' } }); 481 | const result1 = await RethinkDB.db(db1.name).table('test').indexList().run(db1._connection); 482 | expect(result1).to.equal(['other']); 483 | await db1.close(); 484 | 485 | const db2 = new Penseur.Db('penseurtest'); 486 | await db2.connect(); 487 | 488 | await db2.establish({ test: { secondary: false } }); 489 | const result2 = await RethinkDB.db(db2.name).table('test').indexList().run(db2._connection); 490 | expect(result2).to.equal(['other']); 491 | await db2.close(); 492 | }); 493 | 494 | it('creates database with different table indexes (partial overlap)', async () => { 495 | 496 | const db1 = new Penseur.Db('penseurtest'); 497 | await db1.connect(); 498 | await db1.establish({ test: { secondary: ['a', 'b'] } }); 499 | const result1 = await RethinkDB.db(db1.name).table('test').indexList().run(db1._connection); 500 | expect(result1).to.equal(['a', 'b']); 501 | await db1.close(); 502 | 503 | const db2 = new Penseur.Db('penseurtest'); 504 | await db2.connect(); 505 | await db2.establish({ test: { secondary: ['b', 'c'] } }); 506 | const result2 = await RethinkDB.db(db2.name).table('test').indexList().run(db2._connection); 507 | expect(result2).to.equal(['b', 'c']); 508 | await db2.close(); 509 | }); 510 | 511 | it('retains records in existing table', async () => { 512 | 513 | const db1 = new Penseur.Db('penseurtest'); 514 | await db1.connect(); 515 | await db1.establish(['test']); 516 | await db1.test.insert({ id: 1 }); 517 | await db1.close(); 518 | 519 | const db2 = new Penseur.Db('penseurtest'); 520 | await db2.connect(); 521 | await db2.establish({ test: { purge: false } }); 522 | const item = await db2.test.get(1); 523 | expect(item.id).to.equal(1); 524 | await db2.close(); 525 | }); 526 | 527 | it('fails creating a database', async () => { 528 | 529 | const db = new Penseur.Db('penseur-test'); 530 | 531 | await expect(db.establish(['test'])).to.reject(); 532 | await db.close(); 533 | }); 534 | 535 | it('fails connecting to missing server', async () => { 536 | 537 | const db = new Penseur.Db('penseurtest', { host: 'example.com', timeout: 0.001 }); 538 | 539 | await expect(db.establish(['test'])).to.reject(); 540 | await db.close(); 541 | }); 542 | 543 | it('errors on database dbList() error', async () => { 544 | 545 | const db = new Penseur.Db('penseurtest'); 546 | const orig = RethinkDB.dbList; 547 | RethinkDB.dbList = function () { 548 | 549 | RethinkDB.dbList = orig; 550 | 551 | return { 552 | run: function (connection) { 553 | 554 | throw Error('Bad database'); 555 | } 556 | }; 557 | }; 558 | 559 | await expect(db.establish(['test'])).to.reject(); 560 | await db.close(); 561 | }); 562 | 563 | it('errors creating new table', async () => { 564 | 565 | const db = new Penseur.Db('penseurtest'); 566 | 567 | await expect(db.establish(['bad name'])).to.reject(); 568 | await db.close(); 569 | }); 570 | 571 | it('errors emptying existing table', async () => { 572 | 573 | const Override = class extends Penseur.Table { 574 | 575 | empty() { 576 | 577 | throw new Error('failed'); 578 | } 579 | }; 580 | 581 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 582 | 583 | await expect(db.establish({ test: { extended: Override } })).to.reject(); 584 | await db.close(); 585 | }); 586 | }); 587 | 588 | describe('_createTable()', () => { 589 | 590 | it('creates table with custom primary key', async () => { 591 | 592 | const db = new Penseur.Db('penseurtest'); 593 | await db.establish({ test: { primary: 'other', id: 'uuid' } }); 594 | const id = await db.test.insert({ a: 1 }); 595 | const item = await db.test.get(id); 596 | expect(item).to.equal({ other: id, a: 1 }); 597 | await db.close(); 598 | }); 599 | 600 | it('errors on database tableList() error', async () => { 601 | 602 | const db = new Penseur.Db('penseurtest'); 603 | const orig = RethinkDB.db; 604 | let count = 0; 605 | 606 | RethinkDB.db = function (name) { 607 | 608 | ++count; 609 | if (count === 1) { 610 | return orig(name); 611 | } 612 | 613 | RethinkDB.db = orig; 614 | 615 | return { 616 | tableList: function () { 617 | 618 | return { 619 | map: function () { 620 | 621 | return { 622 | run: function (connection) { 623 | 624 | throw new Error('Bad database'); 625 | } 626 | }; 627 | } 628 | }; 629 | } 630 | }; 631 | }; 632 | 633 | await expect(db.establish(['test'])).to.reject(); 634 | await db.close(); 635 | }); 636 | }); 637 | 638 | describe('_verify()', () => { 639 | 640 | it('errors on create table error (id)', async () => { 641 | 642 | const prep = new Penseur.Db('penseurtest'); 643 | const settings = { 644 | penseur_unique_test_a: true, // Test cleanup 645 | test: { 646 | id: { type: 'increment', table: 'allocate' }, 647 | unique: { 648 | path: 'a' 649 | } 650 | } 651 | }; 652 | 653 | await prep.establish(settings); 654 | await prep.close(); 655 | 656 | const db = new Penseur.Db('penseurtest'); 657 | db.table(settings); 658 | 659 | db.test._db._createTable = (options) => { 660 | 661 | throw new Error('Failed'); 662 | }; 663 | 664 | await expect(db.connect()).to.reject(); 665 | await db.close(); 666 | }); 667 | 668 | it('errors on create table error (unique)', async () => { 669 | 670 | const prep = new Penseur.Db('penseurtest'); 671 | const settings = { 672 | penseur_unique_test_a: true, // Test cleanup 673 | test: { 674 | id: 'uuid', 675 | unique: { 676 | path: 'a' 677 | } 678 | } 679 | }; 680 | 681 | await prep.establish(settings); 682 | await prep.close(); 683 | 684 | const db = new Penseur.Db('penseurtest'); 685 | db.table(settings); 686 | 687 | db.test._db._createTable = (options) => { 688 | 689 | throw new Error('Failed'); 690 | }; 691 | 692 | await expect(db.connect()).to.reject(); 693 | await db.close(); 694 | }); 695 | }); 696 | 697 | describe('run()', () => { 698 | 699 | it('makes custom request', async () => { 700 | 701 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 702 | 703 | await db.establish(['test']); 704 | const result = await db.run(db.test.raw.insert({ id: 1, value: 'x' })); 705 | expect(result).to.equal({ 706 | deleted: 0, 707 | errors: 0, 708 | inserted: 1, 709 | replaced: 0, 710 | skipped: 0, 711 | unchanged: 0 712 | }); 713 | 714 | const item = await db.run(db.test.raw.get(1).pluck('id'), {}); 715 | expect(item).to.equal({ id: 1 }); 716 | 717 | const all = await db.run(db.test.raw); 718 | expect(all).to.equal([{ id: 1, value: 'x' }]); 719 | await db.close(); 720 | }); 721 | 722 | it('makes custom request (options)', async () => { 723 | 724 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 725 | 726 | await db.establish(['test']); 727 | const result = await db.run(db.test.raw.insert({ id: 1, value: 'x' }), { profile: true }); 728 | expect(result.profile).to.exist(); 729 | await db.close(); 730 | }); 731 | 732 | it('includes table name on error', async () => { 733 | 734 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015 }); 735 | db.table('test123'); 736 | const err1 = await expect(db.run(db.test123.raw.insert({ id: 1, value: 'x' }))).to.reject('Database disconnected'); 737 | expect(err1.data.table).to.equal('test123'); 738 | 739 | const err2 = await expect(db.run(db.test123.raw.get(1).pluck('id'))).to.reject('Database disconnected'); 740 | expect(err2.data.table).to.equal('test123'); 741 | 742 | const err3 = await expect(db.run(db.test123.raw)).to.reject('Database disconnected'); 743 | expect(err3.data.table).to.equal('test123'); 744 | }); 745 | }); 746 | 747 | describe('_run()', () => { 748 | 749 | it('errors on invalid cursor', async () => { 750 | 751 | const db = new Penseur.Db('penseurtest'); 752 | await db.establish(['test']); 753 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 1 }]); 754 | 755 | const cursor = await db.test.raw.filter({ a: 1 }).run(db._connection); 756 | 757 | const proto = Object.getPrototypeOf(cursor); 758 | const orig = proto.toArray; 759 | proto.toArray = function () { 760 | 761 | proto.toArray = orig; 762 | throw new Error('boom'); 763 | }; 764 | 765 | cursor.close(); 766 | 767 | await expect(db.test.query({ a: 1 })).to.reject(); 768 | }); 769 | }); 770 | 771 | describe('is()', () => { 772 | 773 | it('errors on invalid number of arguments (3)', () => { 774 | 775 | expect(() => { 776 | 777 | Penseur.Db.is('=', 5, '<'); 778 | }).to.throw('Cannot have odd number of arguments'); 779 | }); 780 | 781 | it('errors on invalid number of arguments (1)', () => { 782 | 783 | expect(() => { 784 | 785 | Penseur.Db.is('='); 786 | }).to.throw('Missing value argument'); 787 | }); 788 | }); 789 | 790 | describe('test mode', () => { 791 | 792 | it('logs actions', async () => { 793 | 794 | const test = {}; 795 | const db = new Penseur.Db('penseurtest', { test }); 796 | await db.establish(['test']); 797 | await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 1 }]); 798 | const item = await db.test.get([1, 3]); 799 | expect(item).to.equal([{ id: 3, a: 1 }, { id: 1, a: 1 }]); 800 | 801 | expect(test).to.equal({ 802 | test: [ 803 | { action: 'empty', inputs: null }, 804 | { action: 'index', inputs: null }, 805 | { action: 'insert', inputs: { items: [{ id: 1, a: 1 }, { id: 2, a: 2 }, { id: 3, a: 1 }], options: {} } }, 806 | { action: 'get', inputs: { ids: [1, 3], options: {} } } 807 | ] 808 | }); 809 | }); 810 | 811 | describe('disable()', () => { 812 | 813 | it('simulates an error', async () => { 814 | 815 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015, test: true }); 816 | 817 | await db.establish(['test']); 818 | await db.test.insert({ id: 1, value: 'x' }); 819 | const item1 = await db.test.get(1); 820 | expect(item1.value).to.equal('x'); 821 | 822 | db.disable('test', 'get'); 823 | const err = await expect(db.test.get(1)).to.reject(); 824 | expect(err.data).to.equal({ table: 'test', action: 'get', inputs: undefined }); 825 | 826 | db.enable('test', 'get'); 827 | const item3 = await db.test.get(1); 828 | expect(item3.value).to.equal('x'); 829 | await db.close(); 830 | }); 831 | 832 | it('simulates a response', async () => { 833 | 834 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015, test: true }); 835 | 836 | await db.establish(['test']); 837 | await db.test.insert({ id: 1, value: 'x' }); 838 | const item1 = await db.test.get(1); 839 | expect(item1.value).to.equal('x'); 840 | 841 | db.disable('test', 'get', { value: 'hello' }); 842 | const item2 = await db.test.get(1); 843 | expect(item2).to.equal('hello'); 844 | 845 | db.disable('test', 'get', { value: null }); 846 | const item3 = await db.test.get(1); 847 | expect(item3).to.be.null(); 848 | 849 | db.disable('test', 'get', { value: new Error('stuff') }); 850 | await expect(db.test.get(1)).to.reject('stuff'); 851 | 852 | db.enable('test', 'get'); 853 | const item4 = await db.test.get(1); 854 | 855 | expect(item4.value).to.equal('x'); 856 | await db.close(); 857 | }); 858 | 859 | it('simulates a changes error', async () => { 860 | 861 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015, test: true }); 862 | 863 | await db.establish(['test']); 864 | db.disable('test', 'changes'); 865 | 866 | await expect(db.test.changes({ a: 1 }, Hoek.ignore)).to.reject(); 867 | }); 868 | 869 | it('simulates a changes update error', async () => { 870 | 871 | const team = new Teamwork({ meetings: 1 }); 872 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015, test: true }); 873 | 874 | await db.establish(['test']); 875 | db.disable('test', 'changes', { updates: true }); 876 | 877 | const each = (err, update) => { 878 | 879 | expect(err).to.exist(); 880 | expect(err.flags.willReconnect).to.be.true(); 881 | expect(err.data).to.equal({ table: 'test', action: 'changes', inputs: undefined }); 882 | team.attend(); 883 | }; 884 | 885 | await db.test.changes({ a: 1 }, { handler: each }); 886 | await team.work; 887 | }); 888 | 889 | it('simulates a changes update error (flags)', async () => { 890 | 891 | const team = new Teamwork({ meetings: 1 }); 892 | const db = new Penseur.Db('penseurtest', { host: 'localhost', port: 28015, test: true }); 893 | 894 | await db.establish(['test']); 895 | db.disable('test', 'changes', { updates: true, flags: { willReconnect: false } }); 896 | 897 | const each = (err, update) => { 898 | 899 | expect(err).to.exist(); 900 | expect(err.flags.willReconnect).to.be.false(); 901 | team.attend(); 902 | }; 903 | 904 | await db.test.changes({ a: 1 }, each); 905 | await team.work; 906 | }); 907 | }); 908 | }); 909 | 910 | describe('append()', () => { 911 | 912 | it('errors on unique with multiple values', () => { 913 | 914 | const db = new Penseur.Db('penseurtest'); 915 | 916 | expect(() => db.append([10, 11], { single: true, unique: true })).to.not.throw(); 917 | expect(() => db.append([10, 11], { unique: true })).to.throw('Cannot append multiple values with unique requirements'); 918 | }); 919 | }); 920 | 921 | describe('isSpecial()', () => { 922 | 923 | it('identifies special values', () => { 924 | 925 | const db = new Penseur.Db('penseurtest'); 926 | 927 | expect(db.isSpecial({ type: 'append' })).to.equal(null); 928 | expect(db.isSpecial(db.append('x'))).to.equal('append'); 929 | 930 | expect(Penseur.Db.isSpecial({ type: 'append' })).to.equal(null); 931 | expect(Penseur.Db.isSpecial(db.append('x'))).to.equal('append'); 932 | }); 933 | }); 934 | }); 935 | -------------------------------------------------------------------------------- /test/geo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Lab = require('lab'); 5 | const Penseur = require('..'); 6 | const RethinkDB = require('rethinkdb'); 7 | 8 | 9 | const internals = {}; 10 | 11 | 12 | const { describe, it } = exports.lab = Lab.script(); 13 | const expect = Code.expect; 14 | 15 | 16 | describe('Geo', () => { 17 | 18 | it('returns nearby items', async () => { 19 | 20 | const db = new Penseur.Db('penseurtest'); 21 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: { geo: true } }] } }); 22 | 23 | const items = [ 24 | { id: 'shop', location: [-121.981434, 37.221310], a: 1 }, 25 | { id: 'school', location: [-121.9643744, 37.2158098], a: 2 }, 26 | { id: 'hospital', location: [-121.9570936, 37.2520443], a: 3 } 27 | ]; 28 | 29 | await db.test.insert(items); 30 | 31 | expect(items[0].location).to.equal([-121.981434, 37.221310]); 32 | 33 | const result1 = await db.test.query({ location: db.near([-121.956064, 37.255768], 500) }); 34 | expect(result1).to.equal([{ id: 'hospital', location: [-121.9570936, 37.2520443], a: 3 }]); 35 | 36 | const result2 = await db.test.query({ location: db.near([-121.956064, 37.255768], 10, 'mi') }, { filter: ['id'] }); 37 | expect(result2).to.equal([{ id: 'hospital' }, { id: 'school' }, { id: 'shop' }]); 38 | 39 | const result3 = await db.test.query({ a: 1, location: db.near([-121.956064, 37.255768], 10, 'mi') }, { filter: ['id'] }); 40 | expect(result3).to.equal([{ id: 'shop' }]); 41 | }); 42 | 43 | it('converts geo point to plain value', async () => { 44 | 45 | const db = new Penseur.Db('penseurtest'); 46 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: { geo: true } }] } }); 47 | await db.test.insert({ id: 1, location: [-121.981434, 37.221310] }); 48 | const result = await db.test.get(1); 49 | expect(result).to.equal({ id: 1, location: [-121.981434, 37.221310] }); 50 | 51 | const raw = await RethinkDB.db(db.name).table('test').get(1).run(db._connection); 52 | expect(raw).to.equal({ 53 | id: 1, 54 | location: { 55 | '$reql_type$': 'GEOMETRY', 56 | coordinates: [-121.981434, 37.22131], 57 | type: 'Point' 58 | } 59 | }); 60 | }); 61 | 62 | it('converts geo point to plain value (nested)', async () => { 63 | 64 | const db = new Penseur.Db('penseurtest'); 65 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', source: ['x', 'pos'], options: { geo: true } }] } }); 66 | await db.test.insert({ id: 1, x: { pos: [-121.981434, 37.221310] } }); 67 | const result = await db.test.get(1); 68 | expect(result).to.equal({ id: 1, x: { pos: [-121.981434, 37.221310] } }); 69 | 70 | const raw = await RethinkDB.db(db.name).table('test').get(1).run(db._connection); 71 | expect(raw).to.equal({ 72 | id: 1, 73 | x: { 74 | pos: { 75 | '$reql_type$': 'GEOMETRY', 76 | coordinates: [-121.981434, 37.22131], 77 | type: 'Point' 78 | } 79 | } 80 | }); 81 | }); 82 | 83 | it('reports on a record update (*)', async () => { 84 | 85 | const db = new Penseur.Db('penseurtest'); 86 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: { geo: true } }] } }); 87 | 88 | const changes = []; 89 | const each = (err, item) => { 90 | 91 | expect(err).to.not.exist(); 92 | changes.push(item.after); 93 | }; 94 | 95 | await db.test.changes('*', each); 96 | 97 | await db.test.insert({ id: 1, location: [-121.981434, 37.221310] }); 98 | await db.test.update(1, { location: [-121.98, 37.22] }); 99 | expect(changes).to.equal([ 100 | { id: 1, location: [-121.981434, 37.22131] }, 101 | { id: 1, location: [-121.98, 37.22] } 102 | ]); 103 | 104 | await db.close(); 105 | }); 106 | 107 | it('ignores geo conversion when table geo is false', async () => { 108 | 109 | const db = new Penseur.Db('penseurtest'); 110 | await db.establish({ test: { geo: false, secondary: [{ name: 'location', options: { geo: true } }] } }); 111 | await db.test.insert({ id: 1, location: [-121.981434, 37.221310] }); 112 | const result = await db.test.get(1); 113 | expect(result).to.equal({ id: 1, location: [-121.981434, 37.221310] }); 114 | 115 | const raw = await RethinkDB.db(db.name).table('test').get(1).run(db._connection); 116 | expect(raw).to.equal({ 117 | id: 1, 118 | location: [-121.981434, 37.221310] 119 | }); 120 | }); 121 | 122 | it('ignores geo conversion when no geo index is configured (options)', async () => { 123 | 124 | const db = new Penseur.Db('penseurtest'); 125 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: {} }] } }); 126 | await db.test.insert({ id: 1, location: [-121.981434, 37.221310] }); 127 | const result = await db.test.get(1); 128 | expect(result).to.equal({ id: 1, location: [-121.981434, 37.221310] }); 129 | 130 | const raw = await RethinkDB.db(db.name).table('test').get(1).run(db._connection); 131 | expect(raw).to.equal({ 132 | id: 1, 133 | location: [-121.981434, 37.221310] 134 | }); 135 | }); 136 | 137 | it('ignores geo conversion when no geo index is configured (no options)', async () => { 138 | 139 | const db = new Penseur.Db('penseurtest'); 140 | await db.establish({ test: { geo: true, secondary: ['location'] } }); 141 | await db.test.insert({ id: 1, location: [-121.981434, 37.221310] }); 142 | const result = await db.test.get(1); 143 | expect(result).to.equal({ id: 1, location: [-121.981434, 37.221310] }); 144 | 145 | const raw = await RethinkDB.db(db.name).table('test').get(1).run(db._connection); 146 | expect(raw).to.equal({ 147 | id: 1, 148 | location: [-121.981434, 37.221310] 149 | }); 150 | }); 151 | 152 | it('ignores geo conversion location has more than 2 items', async () => { 153 | 154 | const db = new Penseur.Db('penseurtest'); 155 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: { geo: true } }] } }); 156 | await db.test.insert({ id: 1, location: [-121.981434, 37.221310, 0] }); 157 | const result = await db.test.get(1); 158 | expect(result).to.equal({ id: 1, location: [-121.981434, 37.221310, 0] }); 159 | 160 | const raw = await RethinkDB.db(db.name).table('test').get(1).run(db._connection); 161 | expect(raw).to.equal({ 162 | id: 1, 163 | location: [-121.981434, 37.221310, 0] 164 | }); 165 | }); 166 | 167 | it('ignores geo conversion location that is not a point', async () => { 168 | 169 | const db = new Penseur.Db('penseurtest'); 170 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', source: ['x', 'pos'], options: { geo: true } }] } }); 171 | await db.test.insert({ 172 | id: 1, x: { 173 | pos: { 174 | '$reql_type$': 'GEOMETRY', 175 | coordinates: [-121.981434, 37.22131], 176 | type: 'Other' 177 | } 178 | } 179 | }); 180 | 181 | const result = await db.test.get(1); 182 | expect(result).to.equal({ 183 | id: 1, x: { 184 | pos: { 185 | '$reql_type$': 'GEOMETRY', 186 | coordinates: [-121.981434, 37.22131], 187 | type: 'Other' 188 | } 189 | } 190 | }); 191 | }); 192 | 193 | it('ignores geo conversion on invalid nested index', async () => { 194 | 195 | const db = new Penseur.Db('penseurtest'); 196 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', source: ['x', 'y', 'z', 'a'], options: { geo: true } }] } }); 197 | await db.test.insert({ id: 1, x: { y: 2 } }); 198 | const result = await db.test.get(1); 199 | expect(result).to.equal({ id: 1, x: { y: 2 } }); 200 | }); 201 | 202 | it('ignores nested key on missing parent', async () => { 203 | 204 | const db = new Penseur.Db('penseurtest'); 205 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', source: ['x', 'y', 'z', 'pos'], options: { geo: true } }] } }); 206 | await db.test.insert({ id: 1, x: {} }); 207 | const result = await db.test.get(1); 208 | expect(result).to.equal({ id: 1, x: {} }); 209 | }); 210 | 211 | it('ignores invalid location value (non function)', async () => { 212 | 213 | const db = new Penseur.Db('penseurtest'); 214 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: { geo: true } }] } }); 215 | await db.test.insert({ id: 1, location: 'x' }); 216 | expect(await db.test.get(1)).to.equal({ id: 1, location: 'x' }); 217 | expect(await db.test.query({ location: 'x' })).to.equal([{ id: 1, location: 'x' }]); 218 | }); 219 | 220 | it('ignores invalid location value (non near)', async () => { 221 | 222 | const db = new Penseur.Db('penseurtest'); 223 | await db.establish({ test: { geo: true, secondary: [{ name: 'location', options: { geo: true } }] } }); 224 | await db.test.insert({ id: 1, location: 'x' }); 225 | expect(await db.test.get(1)).to.equal({ id: 1, location: 'x' }); 226 | expect(await db.test.query({ location: db.or(['x', 'y']) })).to.equal([{ id: 1, location: 'x' }]); 227 | }); 228 | 229 | it('errors on multiple near criteria', async () => { 230 | 231 | const db = new Penseur.Db('penseurtest'); 232 | await db.establish({ test: { geo: true, secondary: [{ name: 'location1', options: { geo: true } }, { name: 'location2', options: { geo: true } }] } }); 233 | await db.test.insert([{ id: 'shop', location1: [-121.981434, 37.221310], location2: [-121.981434, 37.221310] }]); 234 | 235 | await expect(db.test.query({ location1: db.near([-121.956064, 37.255768], 500), location2: db.near([-121.956064, 37.255768], 500) })).to.reject('Cannot specify more than one near condition'); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /test/id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Lab = require('lab'); 5 | const Penseur = require('..'); 6 | const RethinkDB = require('rethinkdb'); 7 | 8 | const Id = require('../lib/id'); 9 | 10 | 11 | const internals = {}; 12 | 13 | 14 | const { describe, it } = exports.lab = Lab.script(); 15 | const expect = Code.expect; 16 | 17 | 18 | describe('Id', () => { 19 | 20 | describe('is()', () => { 21 | 22 | it('considers null as non-id', () => { 23 | 24 | expect(Id.is(null)).to.be.false(); 25 | expect(Id.is(undefined)).to.be.false(); 26 | }); 27 | }); 28 | 29 | describe('normalize()', () => { 30 | 31 | it('errors on empty array of ids', async () => { 32 | 33 | const db = new Penseur.Db('penseurtest'); 34 | await db.establish(['test']); 35 | await expect(db.test.get([])).to.reject('Empty array of ids not supported'); 36 | }); 37 | 38 | it('errors on null id', async () => { 39 | 40 | const db = new Penseur.Db('penseurtest'); 41 | await db.establish(['test']); 42 | await expect(db.test.get([null])).to.reject('Invalid null or undefined id'); 43 | }); 44 | 45 | it('errors on undefined id', async () => { 46 | 47 | const db = new Penseur.Db('penseurtest'); 48 | await db.establish(['test']); 49 | await expect(db.test.get([undefined])).to.reject('Invalid null or undefined id'); 50 | }); 51 | }); 52 | 53 | describe('wrap()', () => { 54 | 55 | it('generates keys locally and server-side', async () => { 56 | 57 | const db = new Penseur.Db('penseurtest'); 58 | await db.establish({ test: { id: { type: 'uuid' } } }); 59 | const keys = await db.test.insert([{ id: 'abc', a: 1 }, { a: 2 }]); 60 | expect(keys[0]).to.equal('abc'); 61 | expect(keys[1]).to.match(/^[\da-f]{8}\-[\da-f]{4}\-[\da-f]{4}\-[\da-f]{4}\-[\da-f]{12}$/); 62 | }); 63 | 64 | it('generates keys server-side', async () => { 65 | 66 | const db = new Penseur.Db('penseurtest'); 67 | await db.establish({ test: { id: { type: 'uuid' } } }); 68 | const keys = await db.test.insert([{ id: 'abc', a: 1 }, { id: 'def', a: 2 }]); 69 | expect(keys).to.equal(['abc', 'def']); 70 | }); 71 | }); 72 | 73 | describe('uuid()', () => { 74 | 75 | it('generates keys locally', async () => { 76 | 77 | const db = new Penseur.Db('penseurtest'); 78 | await db.establish({ test: { id: { type: 'uuid' } } }); 79 | const keys = await db.test.insert([{ a: 1 }, { a: 2 }]); 80 | expect(keys.length).to.equal(2); 81 | }); 82 | 83 | it('generates keys locally (implicit config)', async () => { 84 | 85 | const db = new Penseur.Db('penseurtest'); 86 | await db.establish({ test: { id: 'uuid' } }); 87 | const keys = await db.test.insert([{ a: 1 }, { a: 2 }]); 88 | expect(keys.length).to.equal(2); 89 | }); 90 | }); 91 | 92 | describe('increment()', () => { 93 | 94 | it('generates key', async () => { 95 | 96 | const db = new Penseur.Db('penseurtest'); 97 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 98 | const keys = await db.test.insert({ a: 1 }); 99 | expect(keys).to.equal('1'); 100 | }); 101 | 102 | it('generates key (implicit config)', async () => { 103 | 104 | const db = new Penseur.Db('penseurtest'); 105 | await db.establish({ penseur_id_allocate: true, test: { id: 'increment' } }); 106 | const keys = await db.test.insert({ a: 1 }); 107 | expect(keys).to.equal('1'); 108 | }); 109 | 110 | it('generates keys (same table)', async () => { 111 | 112 | const db = new Penseur.Db('penseurtest'); 113 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 114 | const keys = await db.test.insert([{ a: 1 }, { a: 2 }]); 115 | expect(keys).to.equal(['1', '2']); 116 | }); 117 | 118 | it('generates key (different tables)', async () => { 119 | 120 | const db = new Penseur.Db('penseurtest'); 121 | await db.connect(); 122 | await RethinkDB.dbDrop(db.name).run(db._connection); 123 | await db.establish({ test1: { id: { type: 'increment', table: 'allocate' } }, test2: { id: { type: 'increment', table: 'allocate' } } }); 124 | const keys1 = await db.test1.insert({ a: 1 }); 125 | expect(keys1).to.equal('1'); 126 | const keys2 = await db.test2.insert({ a: 1 }); 127 | expect(keys2).to.equal('1'); 128 | await db.close(); 129 | }); 130 | 131 | it('completes an existing incomplete allocation record', async () => { 132 | 133 | const db = new Penseur.Db('penseurtest'); 134 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 135 | await db.allocate.update('test', { value: db.unset() }); 136 | db.test._id.verified = false; 137 | const keys = await db.test.insert({ a: 1 }); 138 | expect(keys).to.equal('1'); 139 | }); 140 | 141 | it('reuses generate record', async () => { 142 | 143 | const db = new Penseur.Db('penseurtest'); 144 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 145 | await db.allocate.update('test', { value: 33 }); 146 | const keys = await db.test.insert({ a: 1 }); 147 | expect(keys).to.equal('34'); 148 | }); 149 | 150 | it('generates base62 id', async () => { 151 | 152 | const db = new Penseur.Db('penseurtest'); 153 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate', radix: 62 } } }); 154 | await db.allocate.update('test', { value: 1324 }); 155 | const keys = await db.test.insert({ a: 1 }); 156 | expect(keys).to.equal('ln'); 157 | }); 158 | 159 | it('customizes key generation', async () => { 160 | 161 | const db = new Penseur.Db('penseurtest'); 162 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate', initial: 1325, radix: 62, record: 'test-id', key: 'v' } } }); 163 | const keys = await db.test.insert({ a: 1 }); 164 | expect(keys).to.equal('ln'); 165 | }); 166 | 167 | it('errors on invalid generate record', async () => { 168 | 169 | const db = new Penseur.Db('penseurtest'); 170 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 171 | await db.allocate.update('test', { value: 'string' }); 172 | db.test._id.verified = false; 173 | await expect(db.test.insert({ a: 1 })).to.reject('Increment id record contains non-integer value: test'); 174 | }); 175 | 176 | it('errors on create table error', async () => { 177 | 178 | const db = new Penseur.Db('penseurtest'); 179 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 180 | db.test._db._createTable = () => Promise.reject(new Error('Failed')); 181 | db.test._id.verified = false; 182 | await expect(db.test.insert({ a: 1 })).to.reject('Failed creating increment id table: test'); 183 | }); 184 | 185 | it('errors on table get error', async () => { 186 | 187 | const db = new Penseur.Db('penseurtest'); 188 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 189 | db.test._id.table.get = () => Promise.reject(new Error('Failed')); 190 | db.test._id.verified = false; 191 | await expect(db.test.insert({ a: 1 })).to.reject('Failed verifying increment id record: test'); 192 | }); 193 | 194 | it('errors on table update error', async () => { 195 | 196 | const db = new Penseur.Db('penseurtest'); 197 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 198 | await db.allocate.update('test', { value: db.unset() }); 199 | db.test._id.table.update = () => Promise.reject(new Error('Failed')); 200 | db.test._id.verified = false; 201 | await expect(db.test.insert({ a: 1 })).to.reject('Failed initializing key-value pair to increment id record: test'); 202 | }); 203 | 204 | it('errors on table insert error', async () => { 205 | 206 | const db = new Penseur.Db('penseurtest'); 207 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 208 | await db.allocate.remove('test'); 209 | db.test._id.table.insert = () => Promise.reject(new Error('Failed')); 210 | db.test._id.verified = false; 211 | await expect(db.test.insert({ a: 1 })).to.reject('Failed inserting increment id record: test'); 212 | }); 213 | 214 | it('errors on table next error', async () => { 215 | 216 | const db = new Penseur.Db('penseurtest'); 217 | await db.establish({ allocate: true, test: { id: { type: 'increment', table: 'allocate' } } }); 218 | db.test._id.table.next = () => Promise.reject(new Error('Failed')); 219 | db.test._id.verified = false; 220 | await expect(db.test.insert([{ a: 1 }, { a: 1 }])).to.reject('Failed allocating increment id: test'); 221 | }); 222 | }); 223 | 224 | describe('validate()', () => { 225 | 226 | it('errors on invalid key structure', async () => { 227 | 228 | const db = new Penseur.Db('penseurtest'); 229 | await db.establish(['test']); 230 | 231 | await expect(db.test.get({ id: [1, 1], other: true })).to.reject('Invalid object id'); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/modifier.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Lab = require('lab'); 5 | const Penseur = require('..'); 6 | 7 | const Special = require('../lib/special'); 8 | 9 | 10 | const internals = {}; 11 | 12 | 13 | const { describe, it } = exports.lab = Lab.script(); 14 | const expect = Code.expect; 15 | 16 | 17 | describe('Modifier', () => { 18 | 19 | describe('update()', () => { 20 | 21 | it('reuses nested fields objects', async () => { 22 | 23 | const db = new Penseur.Db('penseurtest'); 24 | await db.establish(['test']); 25 | 26 | const item = { 27 | id: 1, 28 | a: 1, 29 | b: { 30 | c: 2 31 | } 32 | }; 33 | 34 | await db.test.insert(item); 35 | 36 | const changes = { 37 | a: 2, 38 | b: { 39 | c: db.increment(10) 40 | }, 41 | c: { 42 | d: { 43 | e: 'a' 44 | } 45 | } 46 | }; 47 | 48 | expect(changes.b.c).to.be.an.instanceof(Special); 49 | 50 | await db.test.update(1, changes); 51 | expect(changes.b.c).to.be.an.instanceof(Special); 52 | 53 | const updated = await db.test.get(1); 54 | expect(updated).to.equal({ 55 | id: 1, 56 | a: 2, 57 | b: { 58 | c: 12 59 | }, 60 | c: { 61 | d: { 62 | e: 'a' 63 | } 64 | } 65 | }); 66 | }); 67 | 68 | it('shallow clone once', async () => { 69 | 70 | const db = new Penseur.Db('penseurtest'); 71 | await db.establish(['test']); 72 | 73 | const item = { 74 | id: 1, 75 | a: 1, 76 | b: { 77 | c: 2, 78 | d: 1 79 | } 80 | }; 81 | 82 | await db.test.insert(item); 83 | 84 | const changes = { 85 | a: 2, 86 | b: { 87 | c: db.increment(10), 88 | d: db.increment(10) 89 | } 90 | }; 91 | 92 | expect(changes.b.c).to.be.an.instanceof(Special); 93 | 94 | await db.test.update(1, changes); 95 | expect(changes.b.c).to.be.an.instanceof(Special); 96 | 97 | const updated = await db.test.get(1); 98 | expect(updated).to.equal({ 99 | id: 1, 100 | a: 2, 101 | b: { 102 | c: 12, 103 | d: 11 104 | } 105 | }); 106 | }); 107 | 108 | it('unsets multiple keys', async () => { 109 | 110 | const db = new Penseur.Db('penseurtest'); 111 | await db.establish(['test']); 112 | 113 | const item = { 114 | id: 1, 115 | a: 1, 116 | b: { 117 | c: [2] 118 | } 119 | }; 120 | 121 | await db.test.insert(item); 122 | 123 | const changes = { 124 | a: db.unset(), 125 | b: { 126 | c: db.unset() 127 | } 128 | }; 129 | 130 | await db.test.update(1, changes); 131 | 132 | const updated = await db.test.get(1); 133 | expect(updated).to.equal({ 134 | id: 1, 135 | b: {} 136 | }); 137 | }); 138 | 139 | it('errors on invalid changes (number)', async () => { 140 | 141 | const db = new Penseur.Db('penseurtest'); 142 | await db.establish(['test']); 143 | await db.test.insert({ id: 1, a: 1 }); 144 | await expect(db.test.update(1, 1)).to.reject('Invalid changes object'); 145 | }); 146 | 147 | it('errors on invalid changes (null)', async () => { 148 | 149 | const db = new Penseur.Db('penseurtest'); 150 | await db.establish(['test']); 151 | await db.test.insert({ id: 1, a: 1 }); 152 | await expect(db.test.update(1, null)).to.reject('Invalid changes object'); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /test/unique.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Lab = require('lab'); 5 | const Penseur = require('..'); 6 | 7 | 8 | const internals = {}; 9 | 10 | 11 | const { describe, it } = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | describe('Unique', () => { 16 | 17 | describe('reserve()', () => { 18 | 19 | it('allows setting a unique value', async () => { 20 | 21 | const db = new Penseur.Db('penseurtest'); 22 | const settings = { 23 | penseur_unique_test_a: true, // Test cleanup 24 | test: { 25 | id: 'uuid', 26 | unique: { 27 | path: 'a' 28 | } 29 | } 30 | }; 31 | 32 | await db.establish(settings); 33 | const keys = await db.test.insert([{ a: 1 }, { a: 2 }]); 34 | expect(keys.length).to.equal(2); 35 | }); 36 | 37 | it('allows setting a unique value (nested)', async () => { 38 | 39 | const db = new Penseur.Db('penseurtest'); 40 | const settings = { 41 | penseur_unique_test_a_b: true, // Test cleanup 42 | penseur_unique_test_a_c_d: true, // Test cleanup 43 | test: { 44 | id: 'uuid', 45 | unique: [ 46 | { path: ['a', 'b'] }, 47 | { path: ['a', 'c', 'd'] } 48 | ] 49 | } 50 | }; 51 | 52 | await db.establish(settings); 53 | const keys = await db.test.insert([{ id: 1, a: { b: 1 } }, { id: 2, a: { c: { d: [2] } } }]); 54 | expect(keys.length).to.equal(2); 55 | 56 | await expect(db.test.update(2, { a: { b: 1 } })).to.reject(); 57 | await db.test.update(2, { a: { c: { d: db.append([1, 2]) } } }); 58 | }); 59 | 60 | it('allows updating a unique value', async () => { 61 | 62 | const db = new Penseur.Db('penseurtest'); 63 | const settings = { 64 | penseur_unique_test_a: true, // Test cleanup 65 | test: { 66 | id: 'uuid', 67 | unique: { 68 | path: 'a' 69 | } 70 | } 71 | }; 72 | 73 | await db.establish(settings); 74 | await db.test.insert({ id: '1', a: 1 }); 75 | await db.test.update('1', { a: 2 }); 76 | await db.test.update('1', { a: 1 }); 77 | }); 78 | 79 | it('allows userting a unique value', async () => { 80 | 81 | const db = new Penseur.Db('penseurtest'); 82 | const settings = { 83 | penseur_unique_test_a: true, // Test cleanup 84 | test: { 85 | id: 'uuid', 86 | unique: { 87 | path: 'a' 88 | } 89 | } 90 | }; 91 | 92 | await db.establish(settings); 93 | await db.test.insert({ id: '1', a: 1 }, { merge: true }); 94 | await db.test.insert({ id: '1', a: 2 }, { merge: true }); 95 | await db.test.insert({ id: '2', a: 1 }); 96 | }); 97 | 98 | it('allows appending a unique value', async () => { 99 | 100 | const db = new Penseur.Db('penseurtest'); 101 | const settings = { 102 | penseur_unique_test_a: true, // Test cleanup 103 | test: { 104 | id: 'uuid', 105 | unique: { 106 | path: 'a' 107 | } 108 | } 109 | }; 110 | 111 | await db.establish(settings); 112 | await db.test.insert({ id: '1', a: [1] }); 113 | await db.test.update('1', { a: db.append(2) }); 114 | await db.test.update('1', { a: db.append(1) }); 115 | }); 116 | 117 | it('releases value on unset', async () => { 118 | 119 | const db = new Penseur.Db('penseurtest'); 120 | const settings = { 121 | penseur_unique_test_a: true, // Test cleanup 122 | test: { 123 | id: 'uuid', 124 | unique: { 125 | path: 'a' 126 | } 127 | } 128 | }; 129 | 130 | await db.establish(settings); 131 | await db.test.insert({ id: '1', a: 1 }); 132 | await db.test.update('1', { a: db.unset() }); 133 | await db.test.insert({ id: 2, a: 1 }); 134 | }); 135 | 136 | it.skip('releases value on unset (parent)', async () => { 137 | 138 | const db = new Penseur.Db('penseurtest'); 139 | const settings = { 140 | penseur_unique_test_a_b: true, // Test cleanup 141 | test: { 142 | id: 'uuid', 143 | unique: { 144 | path: ['a', 'b'] 145 | } 146 | } 147 | }; 148 | 149 | await db.establish(settings); 150 | await db.test.insert({ id: '1', a: { b: 1 } }); 151 | await db.test.update('1', { a: db.unset() }); 152 | await db.test.insert({ id: 2, a: { b: 1 } }); 153 | }); 154 | 155 | it('ignores empty object', async () => { 156 | 157 | const db = new Penseur.Db('penseurtest'); 158 | const settings = { 159 | penseur_unique_test_a: true, // Test cleanup 160 | test: { 161 | id: 'uuid', 162 | unique: { 163 | path: 'a' 164 | } 165 | } 166 | }; 167 | 168 | await db.establish(settings); 169 | await db.test.insert({ id: '1', a: {} }); 170 | }); 171 | 172 | it('ignores missing path parent (2 segments)', async () => { 173 | 174 | const db = new Penseur.Db('penseurtest'); 175 | const settings = { 176 | penseur_unique_test_a: true, // Test cleanup 177 | test: { 178 | id: 'uuid', 179 | unique: { 180 | path: ['a', 'b'] 181 | } 182 | } 183 | }; 184 | 185 | await db.establish(settings); 186 | await db.test.insert({ id: '1' }); 187 | }); 188 | 189 | it('allows adding a unique value via update', async () => { 190 | 191 | const db = new Penseur.Db('penseurtest'); 192 | const settings = { 193 | penseur_unique_test_a: true, // Test cleanup 194 | test: { 195 | id: 'uuid', 196 | unique: { 197 | path: 'a' 198 | } 199 | } 200 | }; 201 | 202 | await db.establish(settings); 203 | await db.test.insert({ id: '1' }); 204 | await db.test.update('1', { a: 2 }); 205 | }); 206 | 207 | it('forbids violating a unique value', async () => { 208 | 209 | const db = new Penseur.Db('penseurtest'); 210 | const settings = { 211 | penseur_unique_test_a: true, // Test cleanup 212 | test: { 213 | id: 'uuid', 214 | unique: { 215 | path: 'a' 216 | } 217 | } 218 | }; 219 | 220 | await db.establish(settings); 221 | await expect(db.test.insert([{ a: 1 }, { a: 1 }])).to.reject(); 222 | }); 223 | 224 | it('forbids violating a unique value (keys)', async () => { 225 | 226 | const db = new Penseur.Db('penseurtest'); 227 | const settings = { 228 | penseur_unique_test_a: true, // Test cleanup 229 | test: { 230 | id: 'uuid', 231 | unique: { 232 | path: 'a' 233 | } 234 | } 235 | }; 236 | 237 | await db.establish(settings); 238 | const key = await db.test.insert({ a: { b: [1] } }); 239 | await db.test.update(key, { a: { b: db.append(2) } }); 240 | await db.test.insert({ a: { c: 2, d: 4 } }); 241 | await expect(db.test.insert({ a: { b: 3 } })).to.reject(); 242 | await expect(db.test.insert({ a: { d: 5 } })).to.reject(); 243 | }); 244 | 245 | it('allows same owner changes', async () => { 246 | 247 | const db = new Penseur.Db('penseurtest'); 248 | const settings = { 249 | penseur_unique_test_a: true, // Test cleanup 250 | test: { 251 | id: 'uuid', 252 | unique: { 253 | path: 'a' 254 | } 255 | } 256 | }; 257 | 258 | await db.establish(settings); 259 | await db.test.insert({ id: 1, a: { c: 1 } }); 260 | await db.test.update(1, { a: { c: 5 } }); 261 | }); 262 | 263 | it('releases reservations on update (keys)', async () => { 264 | 265 | const db = new Penseur.Db('penseurtest'); 266 | const settings = { 267 | penseur_unique_test_a: true, // Test cleanup 268 | test: { 269 | id: 'uuid', 270 | unique: { 271 | path: 'a' 272 | } 273 | } 274 | }; 275 | 276 | await db.establish(settings); 277 | await db.test.insert({ id: 1, a: { b: 1 } }); 278 | await db.test.insert({ id: 2, a: { c: 2, d: 4 } }); 279 | await db.test.update(2, { a: { c: db.unset() } }); 280 | await db.test.update(1, { a: { c: 5 } }); 281 | }); 282 | 283 | it('forbids violating a unique value (array)', async () => { 284 | 285 | const db = new Penseur.Db('penseurtest'); 286 | const settings = { 287 | penseur_unique_test_a: true, // Test cleanup 288 | test: { 289 | id: 'uuid', 290 | unique: { 291 | path: 'a' 292 | } 293 | } 294 | }; 295 | 296 | await db.establish(settings); 297 | await db.test.insert({ a: ['b'] }); 298 | const key = await db.test.insert({ a: ['c', 'a'] }); 299 | await expect(db.test.insert({ a: ['b'] })).to.reject(); 300 | await expect(db.test.insert({ a: ['a'] })).to.reject(); 301 | await db.test.update(key, { a: [] }); 302 | await db.test.insert({ a: ['a'] }); 303 | }); 304 | 305 | it('customizes unique table name', async () => { 306 | 307 | const db = new Penseur.Db('penseurtest'); 308 | const settings = { 309 | unique_a: true, // Test cleanup 310 | test: { 311 | id: 'uuid', 312 | unique: { 313 | path: 'a', 314 | table: 'unique_a' 315 | } 316 | } 317 | }; 318 | 319 | await db.establish(settings); 320 | const keys = await db.test.insert([{ id: 1, a: 1 }, { id: 2, a: 2 }]); 321 | expect(keys.length).to.equal(2); 322 | 323 | const items = await db.unique_a.get([1, 2]); 324 | expect(items.length).to.equal(2); 325 | }); 326 | 327 | it('ignores non unique keys', async () => { 328 | 329 | const db = new Penseur.Db('penseurtest'); 330 | const settings = { 331 | penseur_unique_test_a: true, // Test cleanup 332 | test: { 333 | id: 'uuid', 334 | unique: { 335 | path: 'a' 336 | } 337 | } 338 | }; 339 | 340 | await db.establish(settings); 341 | const keys = await db.test.insert([{ b: 1 }, { b: 2 }]); 342 | expect(keys.length).to.equal(2); 343 | }); 344 | 345 | it('ignore further nested values', async () => { 346 | 347 | const db = new Penseur.Db('penseurtest'); 348 | const settings = { 349 | penseur_unique_test_a_b_c: true, // Test cleanup 350 | test: { 351 | id: 'uuid', 352 | unique: { 353 | path: ['a', 'b', 'c'] 354 | } 355 | } 356 | }; 357 | 358 | await db.establish(settings); 359 | await db.test.insert({ a: { b: 1 } }); 360 | }); 361 | 362 | it('ignore further nested values (non existing)', async () => { 363 | 364 | const db = new Penseur.Db('penseurtest'); 365 | const settings = { 366 | penseur_unique_test_a_b_c: true, // Test cleanup 367 | test: { 368 | id: 'uuid', 369 | unique: { 370 | path: ['a', 'b', 'c'] 371 | } 372 | } 373 | }; 374 | 375 | await db.establish(settings); 376 | await db.test.insert({ d: { b: 1 } }); 377 | }); 378 | 379 | it('errors on incrementing unique index', async () => { 380 | 381 | const db = new Penseur.Db('penseurtest'); 382 | const settings = { 383 | penseur_unique_test_a: true, // Test cleanup 384 | test: { 385 | id: 'uuid', 386 | unique: { 387 | path: 'a' 388 | } 389 | } 390 | }; 391 | 392 | await db.establish(settings); 393 | const key = await db.test.insert({ a: 1 }); 394 | await expect(db.test.update(key, { a: db.increment(1) })).to.reject(); 395 | }); 396 | 397 | it('errors on appending a single array to a unique index', async () => { 398 | 399 | const db = new Penseur.Db('penseurtest'); 400 | const settings = { 401 | penseur_unique_test_a: true, // Test cleanup 402 | test: { 403 | id: 'uuid', 404 | unique: { 405 | path: 'a' 406 | } 407 | } 408 | }; 409 | 410 | await db.establish(settings); 411 | const key = await db.test.insert({ a: [1] }); 412 | await expect(db.test.update(key, { a: db.append([2], { single: true }) })).to.reject(); 413 | }); 414 | 415 | it('errors on database unique table get error', async () => { 416 | 417 | const db = new Penseur.Db('penseurtest'); 418 | const settings = { 419 | penseur_unique_test_a: true, // Test cleanup 420 | test: { 421 | id: 'uuid', 422 | unique: { 423 | path: 'a' 424 | } 425 | } 426 | }; 427 | 428 | await db.establish(settings); 429 | db.test._unique.rules[0].table.get = () => Promise.reject(new Error('boom')); 430 | await expect(db.test.insert([{ a: 1 }, { a: 2 }])).to.reject(); 431 | }); 432 | }); 433 | 434 | describe('verify()', () => { 435 | 436 | it('errors on create table error (insert)', async () => { 437 | 438 | const prep = new Penseur.Db('penseurtest'); 439 | const settings = { 440 | penseur_unique_test_a: true, // Test cleanup 441 | test: { 442 | id: 'uuid', 443 | unique: { 444 | path: 'a' 445 | } 446 | } 447 | }; 448 | 449 | await prep.establish(settings); 450 | await prep.close(); 451 | 452 | const db = new Penseur.Db('penseurtest'); 453 | await db.connect(); 454 | db.table(settings); 455 | db.test._db._createTable = (options, callback) => callback(new Error('Failed')); 456 | await expect(db.test.insert({ a: 1 })).to.reject('Failed creating unique table: penseur_unique_test_a'); 457 | }); 458 | 459 | it('errors on create table error (update)', async () => { 460 | 461 | const prep = new Penseur.Db('penseurtest'); 462 | const settings = { 463 | penseur_unique_test_a: true, // Test cleanup 464 | test: { 465 | id: 'uuid', 466 | unique: { 467 | path: 'a' 468 | } 469 | } 470 | }; 471 | 472 | await prep.establish(settings); 473 | const key = await prep.test.insert({ a: 1 }); 474 | await prep.close(); 475 | 476 | const db = new Penseur.Db('penseurtest'); 477 | await db.connect(); 478 | db.table(settings); 479 | db.test._db._createTable = () => Promise.reject(new Error('Failed')); 480 | await expect(db.test.update(key, { a: 2 })).to.reject('Failed creating unique table: penseur_unique_test_a'); 481 | }); 482 | }); 483 | }); 484 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('code'); 4 | const Lab = require('lab'); 5 | const Penseur = require('..'); 6 | 7 | 8 | const internals = {}; 9 | 10 | 11 | const { describe, it } = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | describe('Penseur.utils', () => { 16 | 17 | describe('diff()', () => { 18 | 19 | it('compares object to base with missing properties', () => { 20 | 21 | const item = { a: 1, b: { c: 'd' } }; 22 | expect(Penseur.utils.diff({ a: 2 }, item)).to.equal(item); 23 | }); 24 | 25 | it('includes deleted items (whitelist)', () => { 26 | 27 | expect(Penseur.utils.diff({ a: 2 }, {}, { whitelist: ['a'], deleted: true })).to.equal({ a: null }); 28 | }); 29 | 30 | it('includes deleted items (whitelist missing)', () => { 31 | 32 | expect(Penseur.utils.diff({ a: 2 }, {}, { whitelist: ['b'], deleted: true })).to.be.null(); 33 | }); 34 | 35 | it('includes deleted items (overlap)', () => { 36 | 37 | expect(Penseur.utils.diff({ a: 2, b: 5 }, { b: 5 }, { deleted: true })).to.equal({ a: null }); 38 | }); 39 | 40 | it('includes deleted items (overlap sub)', () => { 41 | 42 | expect(Penseur.utils.diff({ a: 2, b: { x: 1, y: 3 } }, { b: { y: 3 } }, { deleted: true })).to.equal({ a: null, b: { x: null } }); 43 | }); 44 | 45 | it('excludes deleted items (whitelist)', () => { 46 | 47 | expect(Penseur.utils.diff({ a: 2, b: 5 }, { b: 5 }, { whitelist: ['a', 'b'] })).to.be.null(); 48 | }); 49 | 50 | it('compares to null', () => { 51 | 52 | const item = { a: 1, b: { c: 'd' } }; 53 | expect(Penseur.utils.diff(null, item)).to.equal(item); 54 | }); 55 | 56 | it('compares to null with whitelist', () => { 57 | 58 | const item = { a: 1, b: { c: 'd' } }; 59 | expect(Penseur.utils.diff(null, item, { whitelist: ['a'] })).to.equal({ a: 1 }); 60 | }); 61 | 62 | it('compares to null with whitelist (no match)', () => { 63 | 64 | const item = { a: 1, b: { c: 'd' } }; 65 | expect(Penseur.utils.diff(null, item, { whitelist: ['d'] })).to.be.null(); 66 | }); 67 | 68 | it('compares identical objects', () => { 69 | 70 | const item = { a: 1, b: { c: 'd' } }; 71 | expect(Penseur.utils.diff(item, item)).to.be.null(); 72 | }); 73 | 74 | it('compares arrays', () => { 75 | 76 | const item = { a: [1, 2], b: [4, 3], c: 6 }; 77 | expect(Penseur.utils.diff(item, { a: [0, 2], b: 5, c: [1] })).to.equal({ a: { 0: 0 }, b: 5, c: [1] }); 78 | expect(Penseur.utils.diff(item, { a: { x: 1 } })).to.equal({ a: { x: 1 } }); 79 | expect(Penseur.utils.diff({ a: { x: 1 } }, item)).to.equal(item); 80 | expect(Penseur.utils.diff(item, { a: [0, 2], b: 5, c: [1] }, { arrays: false })).to.equal({ a: [0, 2], b: 5, c: [1] }); 81 | expect(Penseur.utils.diff(item, { a: [0, 2], b: [4, 3], c: [1] }, { arrays: false })).to.equal({ a: [0, 2], c: [1] }); 82 | }); 83 | 84 | it('compares nested arrays', () => { 85 | 86 | const item = { a: { b: [1] } }; 87 | expect(Penseur.utils.diff({ a: {} }, { a: { b: [1, 2] } })).to.equal({ a: { b: [1, 2] } }); 88 | expect(Penseur.utils.diff(item, { a: { b: [1, 2] } })).to.equal({ a: { b: { 1: 2 } } }); 89 | expect(Penseur.utils.diff(item, { a: { b: [1, 2] } }, { arrays: false })).to.equal({ a: { b: [1, 2] } }); 90 | }); 91 | 92 | it('compares object to null', () => { 93 | 94 | const item = { a: 1, b: { c: 'd' } }; 95 | expect(Penseur.utils.diff(item, { b: null })).to.equal({ b: null }); 96 | expect(Penseur.utils.diff({ b: null }, item)).to.equal(item); 97 | }); 98 | }); 99 | }); 100 | --------------------------------------------------------------------------------