├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dynamite.js ├── externs └── aws-sdk.js ├── lib ├── BatchGetItemBuilder.js ├── Builder.js ├── Client.js ├── ConditionBuilder.js ├── DeleteItemBuilder.js ├── DescribeTableBuilder.js ├── DynamoRequest.js ├── DynamoResponse.js ├── FakeDynamo.js ├── GetItemBuilder.js ├── PutItemBuilder.js ├── QueryBuilder.js ├── ScanBuilder.js ├── UpdateBuilder.js ├── UpdateExpressionBuilder.js ├── common.js ├── errors.js ├── localUpdater.js ├── reserved.js └── typeUtil.js ├── package-lock.json ├── package.json └── test ├── testBatchGetItem.js ├── testConditions.js ├── testDeleteItem.js ├── testDescribeTable.js ├── testFakeDynamo.js ├── testGetItem.js ├── testGetSet.js ├── testHashKeyOnly.js ├── testLocalUpdater.js ├── testPutItem.js ├── testPutSet.js ├── testQuery.js ├── testScan.js ├── testStringSet.js ├── testTypeUtil.js ├── testUpdateItem.js └── utils └── testUtils.js /.eslintignore: -------------------------------------------------------------------------------- 1 | externs -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": 0, 4 | "linebreak-style": [ 5 | 2, 6 | "unix" 7 | ], 8 | "semi": [ 9 | 2, 10 | "never" 11 | ] 12 | }, 13 | "env": { 14 | "node": true 15 | }, 16 | "extends": "eslint:recommended" 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | node_modules 15 | npm-debug.log 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "6" 5 | - "8" 6 | - "10" 7 | - "11" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013 The Obvious Corporation. 2 | http://obvious.com/ 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | 17 | ------------------------------------------------------------------------- 18 | Apache License 19 | Version 2.0, January 2004 20 | http://www.apache.org/licenses/ 21 | 22 | 23 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 24 | 25 | 1. Definitions. 26 | 27 | "License" shall mean the terms and conditions for use, reproduction, 28 | and distribution as defined by Sections 1 through 9 of this document. 29 | 30 | "Licensor" shall mean the copyright owner or entity authorized by 31 | the copyright owner that is granting the License. 32 | 33 | "Legal Entity" shall mean the union of the acting entity and all 34 | other entities that control, are controlled by, or are under common 35 | control with that entity. For the purposes of this definition, 36 | "control" means (i) the power, direct or indirect, to cause the 37 | direction or management of such entity, whether by contract or 38 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 39 | outstanding shares, or (iii) beneficial ownership of such entity. 40 | 41 | "You" (or "Your") shall mean an individual or Legal Entity 42 | exercising permissions granted by this License. 43 | 44 | "Source" form shall mean the preferred form for making modifications, 45 | including but not limited to software source code, documentation 46 | source, and configuration files. 47 | 48 | "Object" form shall mean any form resulting from mechanical 49 | transformation or translation of a Source form, including but 50 | not limited to compiled object code, generated documentation, 51 | and conversions to other media types. 52 | 53 | "Work" shall mean the work of authorship, whether in Source or 54 | Object form, made available under the License, as indicated by a 55 | copyright notice that is included in or attached to the work 56 | (an example is provided in the Appendix below). 57 | 58 | "Derivative Works" shall mean any work, whether in Source or Object 59 | form, that is based on (or derived from) the Work and for which the 60 | editorial revisions, annotations, elaborations, or other modifications 61 | represent, as a whole, an original work of authorship. For the purposes 62 | of this License, Derivative Works shall not include works that remain 63 | separable from, or merely link (or bind by name) to the interfaces of, 64 | the Work and Derivative Works thereof. 65 | 66 | "Contribution" shall mean any work of authorship, including 67 | the original version of the Work and any modifications or additions 68 | to that Work or Derivative Works thereof, that is intentionally 69 | submitted to Licensor for inclusion in the Work by the copyright owner 70 | or by an individual or Legal Entity authorized to submit on behalf of 71 | the copyright owner. For the purposes of this definition, "submitted" 72 | means any form of electronic, verbal, or written communication sent 73 | to the Licensor or its representatives, including but not limited to 74 | communication on electronic mailing lists, source code control systems, 75 | and issue tracking systems that are managed by, or on behalf of, the 76 | Licensor for the purpose of discussing and improving the Work, but 77 | excluding communication that is conspicuously marked or otherwise 78 | designated in writing by the copyright owner as "Not a Contribution." 79 | 80 | "Contributor" shall mean Licensor and any individual or Legal Entity 81 | on behalf of whom a Contribution has been received by Licensor and 82 | subsequently incorporated within the Work. 83 | 84 | 2. Grant of Copyright License. Subject to the terms and conditions of 85 | this License, each Contributor hereby grants to You a perpetual, 86 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 87 | copyright license to reproduce, prepare Derivative Works of, 88 | publicly display, publicly perform, sublicense, and distribute the 89 | Work and such Derivative Works in Source or Object form. 90 | 91 | 3. Grant of Patent License. Subject to the terms and conditions of 92 | this License, each Contributor hereby grants to You a perpetual, 93 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 94 | (except as stated in this section) patent license to make, have made, 95 | use, offer to sell, sell, import, and otherwise transfer the Work, 96 | where such license applies only to those patent claims licensable 97 | by such Contributor that are necessarily infringed by their 98 | Contribution(s) alone or by combination of their Contribution(s) 99 | with the Work to which such Contribution(s) was submitted. If You 100 | institute patent litigation against any entity (including a 101 | cross-claim or counterclaim in a lawsuit) alleging that the Work 102 | or a Contribution incorporated within the Work constitutes direct 103 | or contributory patent infringement, then any patent licenses 104 | granted to You under this License for that Work shall terminate 105 | as of the date such litigation is filed. 106 | 107 | 4. Redistribution. You may reproduce and distribute copies of the 108 | Work or Derivative Works thereof in any medium, with or without 109 | modifications, and in Source or Object form, provided that You 110 | meet the following conditions: 111 | 112 | (a) You must give any other recipients of the Work or 113 | Derivative Works a copy of this License; and 114 | 115 | (b) You must cause any modified files to carry prominent notices 116 | stating that You changed the files; and 117 | 118 | (c) You must retain, in the Source form of any Derivative Works 119 | that You distribute, all copyright, patent, trademark, and 120 | attribution notices from the Source form of the Work, 121 | excluding those notices that do not pertain to any part of 122 | the Derivative Works; and 123 | 124 | (d) If the Work includes a "NOTICE" text file as part of its 125 | distribution, then any Derivative Works that You distribute must 126 | include a readable copy of the attribution notices contained 127 | within such NOTICE file, excluding those notices that do not 128 | pertain to any part of the Derivative Works, in at least one 129 | of the following places: within a NOTICE text file distributed 130 | as part of the Derivative Works; within the Source form or 131 | documentation, if provided along with the Derivative Works; or, 132 | within a display generated by the Derivative Works, if and 133 | wherever such third-party notices normally appear. The contents 134 | of the NOTICE file are for informational purposes only and 135 | do not modify the License. You may add Your own attribution 136 | notices within Derivative Works that You distribute, alongside 137 | or as an addendum to the NOTICE text from the Work, provided 138 | that such additional attribution notices cannot be construed 139 | as modifying the License. 140 | 141 | You may add Your own copyright statement to Your modifications and 142 | may provide additional or different license terms and conditions 143 | for use, reproduction, or distribution of Your modifications, or 144 | for any such Derivative Works as a whole, provided Your use, 145 | reproduction, and distribution of the Work otherwise complies with 146 | the conditions stated in this License. 147 | 148 | 5. Submission of Contributions. Unless You explicitly state otherwise, 149 | any Contribution intentionally submitted for inclusion in the Work 150 | by You to the Licensor shall be under the terms and conditions of 151 | this License, without any additional terms or conditions. 152 | Notwithstanding the above, nothing herein shall supersede or modify 153 | the terms of any separate license agreement you may have executed 154 | with Licensor regarding such Contributions. 155 | 156 | 6. Trademarks. This License does not grant permission to use the trade 157 | names, trademarks, service marks, or product names of the Licensor, 158 | except as required for reasonable and customary use in describing the 159 | origin of the Work and reproducing the content of the NOTICE file. 160 | 161 | 7. Disclaimer of Warranty. Unless required by applicable law or 162 | agreed to in writing, Licensor provides the Work (and each 163 | Contributor provides its Contributions) on an "AS IS" BASIS, 164 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 165 | implied, including, without limitation, any warranties or conditions 166 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 167 | PARTICULAR PURPOSE. You are solely responsible for determining the 168 | appropriateness of using or redistributing the Work and assume any 169 | risks associated with Your exercise of permissions under this License. 170 | 171 | 8. Limitation of Liability. In no event and under no legal theory, 172 | whether in tort (including negligence), contract, or otherwise, 173 | unless required by applicable law (such as deliberate and grossly 174 | negligent acts) or agreed to in writing, shall any Contributor be 175 | liable to You for damages, including any direct, indirect, special, 176 | incidental, or consequential damages of any character arising as a 177 | result of this License or out of the use or inability to use the 178 | Work (including but not limited to damages for loss of goodwill, 179 | work stoppage, computer failure or malfunction, or any and all 180 | other commercial damages or losses), even if such Contributor 181 | has been advised of the possibility of such damages. 182 | 183 | 9. Accepting Warranty or Additional Liability. While redistributing 184 | the Work or Derivative Works thereof, You may choose to offer, 185 | and charge a fee for, acceptance of support, warranty, indemnity, 186 | or other liability obligations and/or rights consistent with this 187 | License. However, in accepting such obligations, You may act only 188 | on Your own behalf and on Your sole responsibility, not on behalf 189 | of any other Contributor, and only if You agree to indemnify, 190 | defend, and hold each Contributor harmless for any liability 191 | incurred by, or claims asserted against, such Contributor by reason 192 | of your accepting any such warranty or additional liability. 193 | 194 | END OF TERMS AND CONDITIONS 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamite [![Build Status](https://travis-ci.org/Medium/dynamite.svg?branch=master)](https://travis-ci.org/Medium/dynamite) 2 | 3 | Dynamite is a promise-based DynamoDB client. It was created to address performance issues in our previous DynamoDB client. Dynamite will almost always comply with the latest DynamoDB spec on Amazon. 4 | 5 | ## Installation 6 | 7 | $ npm install dynamite 8 | 9 | 10 | ## Running Tests 11 | 12 | Ensure that all of the required node modules are installed in the Dynamite directory by first running: 13 | 14 | $ npm install 15 | 16 | The tests will be run against a `LocalDynamo` service. Currently, there is no way to change the port without modifying the connection code in `test/utils/TestUtils.js`. To run the tests: 17 | 18 | $ npm test 19 | 20 | ## Creating a Client 21 | 22 | ```js 23 | var Dynamite = require('dynamite') 24 | 25 | var options = { 26 | region: 'us-east-1', 27 | accessKeyId: 'xxx', 28 | secretAccessKey: 'xxx' 29 | } 30 | 31 | var client = new Dynamite.Client(options) 32 | ``` 33 | 34 | Options requires all of: 35 | 36 | * region 37 | * accessKeyId 38 | * secretAccessKey 39 | 40 | If a `region` key is not provided in the `options` hash but a `endpoint` key is present, Dynamite will try to infer the region from the `host` key. 41 | 42 | Options can also optionally take a hash with a key `dbClient` which points to an object that implements the AWS SDK interface for node.js. 43 | 44 | #### Optional Options Keys 45 | 46 | * `sslEnabled`: a boolean to turn ssl on or off for the connection. 47 | * `endPoint`: the address of the DynamoDB instance to try to communicate with. 48 | * `retryHandler`: a `function(method, table, response)` that will be triggered if Dynamite needs to retry a command. 49 | 50 | ### Foreword: Kew and You 51 | 52 | All functions return [Kew](https://github.com/Medium/kew) promises on `execute()`. These functions will all then take the form: 53 | 54 | ```js 55 | client.fn(params) 56 | .execute() 57 | .then(function(){ 58 | // handle success 59 | }) 60 | .fail(function(e) { 61 | // handle failure 62 | }) 63 | .fin(function() { 64 | // when all is said and done 65 | }) 66 | ``` 67 | 68 | Therefore, these docs will focus more on function signatures and assume that the developer using those functions will comply with the Kew API in turn. 69 | 70 | ## Tables 71 | 72 | ### Creating a Table 73 | 74 | Table creation is part of the database's concerns and thus doesn't have its own pretty API built into Dynamite. A snippet successfully creating a table that is compliant with the 2012 DynamoDB spec can be found in `test/utils/TestUtils.js`. 75 | 76 | 77 | ### Describing a Table 78 | 79 | Tables can have descriptions. Retrieve them with: 80 | 81 | ```js 82 | client.describeTable('table-name') 83 | ``` 84 | 85 | ## Conditions 86 | 87 | Conditions ensure that certain properties of the item are either absent or equal to a certain value before allowing whatever operation to which they were supplied to mutate the item. They become very useful when items should only be updated if they are missing a field or are of the wrong value. There currently exist two kinds of conditions: `expectAttributeEquals` and `expectAttributeAbsent`. Every operation has particular behaviors when conditions are or are not met. 88 | 89 | Adding conditions to an operation is fairly trivial: 90 | 91 | ```js 92 | var conditions = client.newConditionBuilder() 93 | .expectAttributeEquals('age', 29) 94 | 95 | client.fn('some-table') 96 | .withCondition(conditions) 97 | .execute() 98 | .then(function () { 99 | // handle the operation output 100 | }) 101 | ``` 102 | 103 | There is also a helper method for building conditions from a JSON object. 104 | 105 | ```js 106 | var conditions = client.conditions({age: 29}) 107 | client.fn('some-table') 108 | .withCondition(conditions) 109 | .execute() 110 | ``` 111 | 112 | If a condition fails, the promise will be rejected with a conditional error, 113 | which you can detect with the `isConditionalError` method 114 | 115 | ```js 116 | client.fn('some-table') 117 | .withCondition(client.conditions({age: 29}) 118 | .execute() 119 | .fail(function (e) { 120 | if (!client.isConditionalError(e)) { 121 | throw new Error('Unexpected age; conditional check failed') 122 | } else { 123 | throw e 124 | } 125 | }) 126 | ``` 127 | 128 | Catching all conditional errors is a common idiom, so there is a 129 | `throwUnlessConditionalError` helper method for this case. 130 | 131 | ```js 132 | client.fn('some-table') 133 | .withCondition(client.conditions({age: 29}) 134 | .execute() 135 | .fail(client.throwUnlessConditionalError) 136 | ``` 137 | 138 | ## Getting an Item From a Table 139 | 140 | ```js 141 | client.getItem('user-table') 142 | .setHashKey('userId', 'userA') 143 | .setRangeKey('column', '@') 144 | .execute() 145 | .then(function(data) { 146 | // data.result: the resulting object 147 | }) 148 | ``` 149 | 150 | If an item does not exist, `data.result` will be `undefined`. 151 | 152 | ### Getting Select Attributes 153 | 154 | ```js 155 | client.getItem('user-table') 156 | .setHashKey('userId', 'userA') 157 | .setRangeKey('column', '@') 158 | .selectAttributes(['userId', 'column']) 159 | .execute() 160 | .then(function(data) { 161 | // data.result: the resulting object 162 | // only the attributes passed into selectAttributes() 163 | // appear as keys in data.result 164 | }) 165 | ``` 166 | 167 | 168 | 169 | ### Batch Get 170 | 171 | The batch get API allows you to request multiple items with specific primary keys, from different 172 | tables, in a single fetch. 173 | 174 | ```js 175 | client.newBatchGetBuilder() 176 | .requestItems('user', [{'userId': 'userA', 'column': '@'}, {'userId': 'userB', 'column': '@'}]) 177 | .requestItems('phones', [{'userId': 'userA', 'column': 'phone1'}, {'userId': 'userB', 'column': 'phone1'}]) 178 | .execute() 179 | ``` 180 | 181 | `requestItems` can be called multiple times, with a table name and an array of objects representing 182 | primary keys, in the form `{hashKey: 123, rangeKey: 456}`. 183 | 184 | 185 | 186 | ## Putting an Item Into a Table 187 | 188 | Items are handled as JavaScript Objects by the client. These are then converted into an AWS specific format and sent off. The only accepted types of data that can be stored in DynamoDB are Strings, Numbers, and Sets (Arrays). Sets can contain either only Numbers or Strings. 189 | 190 | ```js 191 | client.putItem('user-table', { 192 | userId: 'userA', 193 | column: '@', 194 | age: 30, 195 | company: 'Medium', 196 | nickNames: ['Ev', 'Evan'], 197 | postIds: [1, 2, 3] 198 | }) 199 | ``` 200 | 201 | ### Overrides 202 | 203 | If an item with the same hash and range keys as the one that is being inserted, the old item will be replaced with the item that is being put in its place. 204 | 205 | ```js 206 | // initialData = [{userId: 'userA', column: '@', age: 27] 207 | 208 | client.putItem('user-table', { 209 | userId: 'userA', 210 | column: '@', 211 | height: 72 212 | }) 213 | ``` 214 | 215 | If the item above were to be retrieved from the table `user-table`, then age would be undefined and a new key `height` would be available. 216 | 217 | ### Conditional Writes 218 | 219 | #### expectAttributeEquals 220 | 221 | The item will only be replaced if the field `field` in the item is equal to the param `value`. If the item does not exist in the table, or the condition is not met, the request will fail. 222 | 223 | #### expectAttributeAbsent 224 | 225 | The item will only be replaced if the field `field` is not set in the item in the table. If the item does not exist in the table, then the item will be written to the table. If the field `field` exists for the item in the table, the request will fail. 226 | 227 | 228 | ## Deleting Items From a Table 229 | 230 | If the hash key and range key match an item, it will be deleted. Upon success, the function returns the previous attributes and values of the deleted item. 231 | 232 | ```js 233 | client.deleteItem('user-table') 234 | .setHashKey('userId', 'userA') 235 | .setRangeKey('column', '@') 236 | .execute() 237 | .then(function (data) { 238 | // data.result will contain the origin item attributes and their corresponding values 239 | }) 240 | ``` 241 | 242 | ### Conditional Deletes 243 | 244 | #### expectAttributeEquals 245 | 246 | If an item does not exist, then the request will fail. Otherwise, if the condition is met, the item will be deleted. 247 | 248 | #### expectAttributeAbsent 249 | 250 | If an item does not exist, then the request will fail. Otherwise, if the condition is met, the item will be deleted. 251 | 252 | 253 | ## Updating an Item 254 | 255 | There are three methods available to modify columns for items: `putAttribute(field, value)`, `deleteAttribute(field)`, and `addToAttribute(field, value)`. 256 | 257 | If an item does not exist, the update query will create the item and update its attributes accordingly. 258 | 259 | If a value is updated on an attribute that does not exist, the attribute will be added to the item and set to the `value` passed to `putsAttribute(field, value)`. If an attribute does not exist and it's value is incremented, that attribute will be added to the item and it's value will be set to the `value` passed to `addToAttribute(field, value)`. If an attribute is deleted and it does not exist, the operation becomes a nonsense operation and has no effect on the item. 260 | 261 | Putting empty attributes causes the whole update query to fail. 262 | 263 | ```js 264 | // initialData = [{userId: 'userA', column: '@', age: 27, weight: 180] 265 | 266 | client.newUpdateBuilder('user-table') 267 | .setHashKey('userId', 'userA') 268 | .setRangeKey('column', '@') 269 | .enableUpsert() 270 | .putAttribute('age', 30) 271 | .addToAttribute('age', 1) 272 | .deleteAttribute('weight') 273 | .putAttribute('height', 72) 274 | .execute() 275 | .then(function (data) { 276 | // data.result == {userId: 'userA', column: '@', age: 31, height: 72} 277 | }) 278 | ``` 279 | 280 | #### Conditional Updates 281 | 282 | Conditions should be added with `withCondition` before any update commands. 283 | 284 | ##### expectAttributeEquals 285 | 286 | If the item does not exist, the update query will fail. 287 | 288 | ##### expectAttributeAbsent 289 | 290 | If the item does not exist, the update query will create the item and update its attributes accordingly. 291 | 292 | ## Querying a Table 293 | 294 | Amazon features [extensive documentation](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html) describing querying and scanning in great detail. 295 | 296 | A Query operation searches only primary key attribute values and supports a subset of comparison operators on key attribute values to refine the search process. A query returns all of the item data for the matching primary keys (all of each item's attributes) up to 1 MB of data per query operation. A Query operation always returns results, but can return empty results. 297 | 298 | A Query operation seeks the specified composite primary key, or range of keys, until one of the following events occur: 299 | 300 | + The result set is exhausted. 301 | + The number of items retrieved reaches the value of the Limit parameter, if specified. 302 | + The amount of data retrieved reaches the maximum result set size limit of 1 MB. 303 | 304 | ### Usage 305 | 306 | Our initial data set: 307 | 308 | ```js 309 | [ 310 | {"postId": "post1", "column": "@", "title": "This is my post", "content": "And here is some content!", "tags": ['foo', 'bar']}, 311 | {"postId": "post1", "column": "/comment/timestamp/002123", "comment": "this is slightly later"}, 312 | {"postId": "post1", "column": "/comment/timestamp/010000", "comment": "where am I?"}, 313 | {"postId": "post1", "column": "/comment/timestamp/001111", "comment": "HEYYOOOOO"}, 314 | {"postId": "post1", "column": "/comment/timestamp/001112", "comment": "what's up?"}, 315 | {"postId": "post1", "column": "/canEdit/user/AAA", "userId": "AAA"} 316 | ] 317 | ``` 318 | 319 | Querying all items whose postId is `post1`: 320 | 321 | ```js 322 | client.newQueryBuilder('comments-table') 323 | .setHashKey('postId', 'post1') 324 | .execute() 325 | .then(function (data) { 326 | // data.result is an array of posts whose hash key is `post1` 327 | }) 328 | ``` 329 | 330 | The result of the query will be a DynamoResult object with a `result` property for the result set. 331 | 332 | DynamoResult also has two methods: 333 | 334 | + hasNext(): boolean 335 | 336 | Returns whether there are remaining results for this query. 337 | 338 | + next(): Promise.<DynamoResult> 339 | 340 | Executes a new query that fetches the next page of results. 341 | 342 | There are also a variety of methods that refine and restrict the returned set of results that operate on the indexed range key, which in our sample case is `column`. 343 | 344 | #### getCount() 345 | 346 | Get the count of the number of items, not the actual items themselves. 347 | 348 | ```js 349 | client.newQueryBuilder('comments-table') 350 | .setHashKey('postId', 'post1') 351 | .getCount() 352 | ``` 353 | 354 | #### scanForward() 355 | 356 | Demand that items be returned in ascending ASCII or numerical value. This is the default. 357 | 358 | #### scanBackward() 359 | 360 | Demand that items be returned in descending ASCII or numerical value. 361 | 362 | #### setStartKey(key) 363 | 364 | Start the query at a specified hash key. Useful when your request is returned in chunks and subsequent chunks need to be retrieved after the current batch is processed. 365 | 366 | When partial results are returned, the `LastEvaluatedKey` can be passed in as an argument to `setStartKey()` on the next query to get the next section of results. 367 | 368 | In general, calling setStartKey directly is discouraged in favor of using the `next()` method described above. 369 | 370 | #### setLimit(max) 371 | 372 | Return at most `max` items. Note that if the response will be larger than 1mb, then at most 1mb of data is returned, and the next batch of items needs to be queried while specifying that the query start at the `LastEvaluatedKey`. That key is returned with the results of the current query. 373 | 374 | #### indexBeginsWith(range_key, key_part) 375 | 376 | Return only items where the range key begins with `key_part`. For instance, retrieve all comments for posts with a, in our case unique, hash key of `post1`. 377 | 378 | ```js 379 | client.newQueryBuilder('comments-table') 380 | .setHashKey('postId', 'post1') 381 | .indexBeginsWith('column', '/comment/') 382 | ``` 383 | 384 | #### indexBetween(range_key, key_part_start, key_part_end) 385 | 386 | Return only items whose range key is "between" the start and end keys. The range key will be compared to the start and end keys in a lexicographic manner. So 'b' is "between" 'a' and 'c'. 387 | 388 | Retrieve all comments for posts with the hash key `post1` up until the `009999` timestamp: 389 | 390 | ```js 391 | client.newQueryBuilder('comments-table') 392 | .setHashKey('postId', 'post1') 393 | .indexBetween('column', '/comment/', '/comment/timestamp/009999') 394 | ``` 395 | 396 | #### indexLessThan(range_key, value) 397 | #### indexLessThanEqual(range_key, value) 398 | #### indexGreaterThan(range_key, value) 399 | #### indexGreaterThanEqual(range_key, value) 400 | #### indexEqual(range_key, value) 401 | 402 | Return all items whose range keys comply with the afore-listed operations. 403 | 404 | #### selectAttributes(attributes[]) 405 | 406 | Returned items will be stripped of all attributes except their hash key, range key, and the provided array of strings `attributes`. 407 | 408 | ## Scanning A Table 409 | 410 | Amazon features [extensive documentation](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html) describing querying and scanning in great detail. 411 | 412 | A Scan operation examines every item in the table. You can specify filters to apply to the results to refine the values returned to you, after the scan has finished. Amazon DynamoDB puts a 1 MB limit on the scan (the limit applies before the results are filtered). A Scan can result in no table data meeting the filter criteria. 413 | 414 | Scan supports a specific set of comparison operators. For information about each comparison operator available for scan operations, go to the API entry for Scan in the Amazon DynamoDB API Reference. 415 | 416 | ### Usage 417 | 418 | Our initial data set: 419 | 420 | ```js 421 | [ 422 | {"userId": "c", "column": "@", "post": "3", "email": "1@medium.com"}, 423 | {"userId": "b", "column": "@", "post": "0", "address": "800 Market St. SF, CA"}, 424 | {"userId": "a", "column": "@", "post": "5", "email": "3@medium"}, 425 | {"userId": "d", "column": "@", "post": "2", "twitter": "haha"}, 426 | {"userId": "e", "column": "@", "post": "2", "twitter": "hoho"}, 427 | {"userId": "f", "column": "@", "post": "4", "description": "Designer", "email": "h@w.com"}, 428 | {"userId": "h", "column": "@", "post": "6", "tags": ['foo', 'bar']} 429 | ] 430 | ``` 431 | 432 | A simple scan looks like this: 433 | 434 | ```js 435 | client.newScanBuilder('user-table') 436 | .execute() 437 | .then(function (data) { 438 | // data.result contains all of the users 439 | }) 440 | ``` 441 | 442 | If your dataset contains more than 1 MB of data, the `data` that is returned will contain a `LastEvaluatedKey` key that will tell you what the last evaluated key for the scan was, so you can start the next `scan` there by passing the `LastEvaluatedKey` to `setStartKey(key)`. 443 | 444 | #### .filterAttributeEquals(field, value) 445 | 446 | Include items whose `field` equals `value`. 447 | 448 | ```js 449 | client.newScanBuilder('user-table') 450 | .filterAttributeEquals('twitter', 'haha') 451 | .execute() 452 | .then(function (data) { 453 | // data.result #=> [{"userId": "d", "column": "@", "post": "2", "twitter": "haha"}] 454 | }) 455 | ``` 456 | 457 | The other `filterAttribute*` functions are used in the exact same way. 458 | 459 | #### .filterAttributeNotEquals(field, value) 460 | 461 | Include items whose `field` does not equal `value`. 462 | 463 | #### .filterAttributeLessThanEqual(field, value) 464 | 465 | Include items whose `field` is less than or equal to `value`. 466 | 467 | #### .filterAttributeLessThan(field, value) 468 | 469 | Include items whose `field` is less than `value`. 470 | 471 | #### .filterAttributeGreaterThanEqual(field, value) 472 | 473 | Include items whose `field` is greater than or equal to `value`. 474 | 475 | #### .filterAttributeGreaterThan(field, value) 476 | 477 | Include items whose `field` is greater than `value`. 478 | 479 | #### .filterAttributeNotNull(field) 480 | 481 | Include items whose `field` is not `null`, or doesn't exist. 482 | 483 | #### .filterAttributeContains(field, value) 484 | 485 | Include items whose `field` contains `value`. 486 | 487 | If an item's `field` attribute is a string, `filterAttributeContains` will search for `value` in that field's value. If an item's `field` attribute is a set, `filterAttributeContains` will search for `value` in that set. 488 | 489 | #### .filterAttributeNotContains(field, value) 490 | 491 | Include items whose `field` does not contain `value`. Essentially the inverse of `filterAttributeContains`. 492 | 493 | #### .filterAttributeBeginsWith(field, value) 494 | 495 | Include items whose `field` attribute begins with `value`. 496 | 497 | #### .filterAttributeBetween(field, lower, upper) 498 | 499 | Include items whose `field` attribute's value is between `lower` and `upper`, exclusive. 500 | 501 | #### .filterAttributeIn(field, array_of_values) 502 | 503 | Filter out rows where field is not one of the values in `array_of_values`. 504 | -------------------------------------------------------------------------------- /dynamite.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Client: require('./lib/Client'), 3 | FakeDynamo: require('./lib/FakeDynamo'), 4 | ConditionBuilder: require('./lib/ConditionBuilder') 5 | } 6 | -------------------------------------------------------------------------------- /externs/aws-sdk.js: -------------------------------------------------------------------------------- 1 | 2 | // Require an event emitter, because some of these apis return emitters. 3 | var EventEmitter = require('events').EventEmitter 4 | 5 | var awsResponse = { 6 | CapacityUnits: 0, 7 | UnprocessedKeys: [] 8 | } 9 | 10 | var queryResponse = { 11 | TableName: null, 12 | AttributesToGet: [], 13 | Limit: 1, 14 | ConsistentRead: true, 15 | Count: true, 16 | HashKeyValue: { 17 | S: '', 18 | N: 1, 19 | B: 'x', 20 | SS: [], 21 | NS: [], 22 | BS: [] 23 | }, 24 | RangeKeyCondition: { 25 | AttributeValueList: [], 26 | ComparisonOperator: null 27 | }, 28 | ScanIndexForward: true, 29 | ExclusiveStartKey: { 30 | HashKeyElement: {S: ''}, 31 | RangeKeyElement: {S: ''} 32 | } 33 | } 34 | 35 | /** @constructor */ 36 | function DynamoDB() {} 37 | 38 | module.exports = { 39 | /** @constructor */ 40 | DynamoDB: DynamoDB, 41 | 42 | config: { 43 | update: function (options) {} 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/BatchGetItemBuilder.js: -------------------------------------------------------------------------------- 1 | var DynamoRequest = require('./DynamoRequest') 2 | var Builder = require('./Builder') 3 | var Q = require('kew') 4 | var util = require('util') 5 | 6 | // TODO(nick): Add an iterative API. 7 | 8 | /** @const */ 9 | var BATCH_LIMIT = 100 10 | 11 | /** 12 | * @param {Object} options 13 | * @constructor 14 | * @extends {Builder} 15 | */ 16 | function BatchGetItemBuilder(options) { 17 | Builder.call(this, options) 18 | this._tableAttributes = {} 19 | this._tableConsistentRead = {} 20 | this._tableKeys = {} 21 | } 22 | util.inherits(BatchGetItemBuilder, Builder) 23 | 24 | 25 | /** 26 | * Specify if we should do consistent read on a certain table. 27 | * 28 | * @param {string} table The name of the table to configure 29 | * @param {boolean} consistentRead Indicate if we need to do consistent read on the 30 | * given table. 31 | * @return {BatchGetItemBuilder} 32 | */ 33 | BatchGetItemBuilder.prototype.setConsistentReadForTable = function (table, consistentRead) { 34 | this._tableConsistentRead[table] = consistentRead 35 | return this 36 | } 37 | 38 | 39 | /** 40 | * Request items from a certain table. 41 | * 42 | * @param {string} table The name of the table to request items from 43 | * @param {Array.} keys A set of primary keys to fetch. 44 | * @return {BatchGetItemBuilder} 45 | */ 46 | BatchGetItemBuilder.prototype.requestItems = function (table, keys) { 47 | if (!this._tableKeys[table]) this._tableKeys[table] = [] 48 | this._tableKeys[table].push.apply(this._tableKeys[table], keys) 49 | return this 50 | } 51 | 52 | 53 | BatchGetItemBuilder.prototype.execute = function () { 54 | var promises = [] 55 | var count = 0 56 | var batchTableKeys = {} 57 | 58 | // Divide the items into batches, BATCH_LIMIT (100) items each. 59 | for (var tableName in this._tableKeys) { 60 | var keys = this._tableKeys[tableName] 61 | batchTableKeys[tableName] = [] 62 | for (var i = 0; i < keys.length; i++) { 63 | if (count === BATCH_LIMIT) { 64 | promises.push(this._getAllItems(this._buildDynamoRequest(batchTableKeys))) 65 | batchTableKeys = {} 66 | batchTableKeys[tableName] = [] 67 | count = 0 68 | } 69 | count += 1 70 | batchTableKeys[tableName].push(keys[i]) 71 | } 72 | } 73 | if (count > 0) promises.push(this._getAllItems(this._buildDynamoRequest(batchTableKeys))) 74 | var builder = this 75 | 76 | return Q.all(promises) 77 | .then(function (batches) { 78 | var all = batches[0] 79 | for (var i = 1; i < batches.length; i++) builder._mergeTwoBatches(all, batches[i]) 80 | return all 81 | }) 82 | .then(this.prepareOutput.bind(this)) 83 | .fail(this.emptyResults) 84 | } 85 | 86 | 87 | /** 88 | * Return an object that represents the request data, for the purpose of 89 | * logging/debugging. 90 | * 91 | * @return {Object} Information about this request 92 | */ 93 | BatchGetItemBuilder.prototype.toObject = function () { 94 | return { 95 | options: this._options, 96 | attributes: this._tableAttributes, 97 | consistent: this._tableConsistentRead, 98 | items: this._tableKeys 99 | } 100 | } 101 | 102 | 103 | BatchGetItemBuilder.prototype._buildDynamoRequest = function (keys) { 104 | return new DynamoRequest(this.getOptions()) 105 | .setBatchTableAttributes(this._tablePrefix, this._tableAttributes) 106 | .setBatchTableConsistent(this._tablePrefix, this._tableConsistentRead) 107 | .setBatchRequestItems(this._tablePrefix, keys) 108 | .returnConsumedCapacity() 109 | .build() 110 | } 111 | 112 | 113 | /** 114 | * Merge two batches of responses into one batch. 115 | * 116 | * @param{Object} batch One batch data returned from Dynamo. 117 | * @param{Object} anotherBatch Another batch data returned from Dynamo 118 | */ 119 | BatchGetItemBuilder.prototype._mergeTwoBatches = function (batch, anotherBatch) { 120 | for (var tableName in anotherBatch.Responses) { 121 | if (!(tableName in batch.Responses)) { 122 | batch.Responses[tableName] = {Items: [], ConsumedCapacityUnits: 0} 123 | } 124 | var items = batch.Responses[tableName] 125 | items.push.apply(items, anotherBatch.Responses[tableName]) 126 | batch.Responses[tableName].ConsumedCapacityUnits += 127 | anotherBatch.Responses[tableName].ConsumedCapacityUnits 128 | } 129 | } 130 | 131 | 132 | /** 133 | * Get all the items and handle the Dynamo API limit size limit. 134 | * 135 | * @param {Object.>} queryData A map from table name to 136 | * requested keys in Dynamo API format. 137 | * @return {Q.Promise.} The object is a typical Dynamo response. 138 | */ 139 | BatchGetItemBuilder.prototype._getAllItems = function (queryData) { 140 | var builder = this 141 | 142 | return this.request("batchGetItem", queryData) 143 | .then(function (data) { 144 | var unprocessedKeys = data.UnprocessedKeys 145 | if (unprocessedKeys && Object.keys(unprocessedKeys).length > 0) { 146 | // If there are unprocessed keys, keep fetching and append the results to 147 | // the current results. 148 | return builder._getAllItems( 149 | new DynamoRequest(builder.getOptions()) 150 | .setRequestItems(unprocessedKeys) 151 | .returnConsumedCapacity() 152 | .build()) 153 | .then(function (moreData) { 154 | builder._mergeTwoBatches(data, moreData) 155 | data.UnprocessedKeys = {} 156 | return data 157 | }) 158 | } else { 159 | return data 160 | } 161 | }) 162 | .failBound(this.convertErrors, null, {data: queryData, isWrite: false}) 163 | } 164 | 165 | 166 | module.exports = BatchGetItemBuilder 167 | -------------------------------------------------------------------------------- /lib/Builder.js: -------------------------------------------------------------------------------- 1 | var util = require('util') 2 | var Q = require('kew') 3 | var errors = require('./errors') 4 | var DynamoResponse = require('./DynamoResponse') 5 | 6 | /** 7 | * @constructor 8 | * @param {Object} options 9 | */ 10 | function Builder(options) { 11 | this._options = options || {} 12 | this._retryHandler = this._options.retryHandler 13 | } 14 | 15 | Builder.prototype.setHashKey = function (keyName, keyVal) { 16 | this._hashKey = { 17 | name: keyName, 18 | val: keyVal 19 | } 20 | return this 21 | } 22 | 23 | Builder.prototype.setRangeKey = function (keyName, keyVal) { 24 | this._rangeKey = { 25 | name: keyName, 26 | val: keyVal 27 | } 28 | return this 29 | } 30 | 31 | Builder.prototype.getRetryHandler = function () { 32 | return this._retryHandler 33 | } 34 | 35 | Builder.prototype.setRetryHandler = function (retryHandler) { 36 | this._retryHandler = retryHandler 37 | return this 38 | } 39 | 40 | Builder.prototype.getOptions = function () { 41 | return this._options 42 | } 43 | 44 | Builder.prototype.setDatabase = function (db) { 45 | this._db = db 46 | return this 47 | } 48 | 49 | Builder.prototype.setConsistent = function (isConsistent) { 50 | this._isConsistent = isConsistent 51 | return this 52 | } 53 | 54 | Builder.prototype.consistentRead = function () { 55 | return this.setConsistent(true) 56 | } 57 | 58 | Builder.prototype.getPrefix = function () { 59 | return this._tablePrefix 60 | } 61 | 62 | Builder.prototype.setPrefix = function (prefix) { 63 | this._tablePrefix = prefix 64 | return this 65 | } 66 | 67 | Builder.prototype.setTable = function (table) { 68 | this._table = table 69 | return this 70 | } 71 | 72 | Builder.prototype.setLimit = function (limit) { 73 | this._limit = limit 74 | return this 75 | } 76 | 77 | Builder.prototype.scanForward = function () { 78 | this._shouldScanForward = true 79 | return this 80 | } 81 | 82 | Builder.prototype.scanBackward = function () { 83 | this._shouldScanForward = false 84 | return this 85 | } 86 | 87 | Builder.prototype.getCount = function () { 88 | this._isCount = true 89 | return this 90 | } 91 | 92 | Builder.prototype.withFilter = function (filter) { 93 | if (!this._filters) this._filters = [] 94 | if (filter) this._filters.push(filter) 95 | return this 96 | } 97 | 98 | Builder.prototype.withCondition = function (condition) { 99 | if (!this._conditions) this._conditions = [] 100 | if (condition) this._conditions.push(condition) 101 | return this 102 | } 103 | 104 | Builder.prototype.selectAttributes = function (attributes) { 105 | if (!attributes) return this 106 | if (!Array.isArray(attributes)) attributes = Array.prototype.slice.call(arguments, 0) 107 | this._attributes = attributes 108 | return this 109 | } 110 | 111 | /** @this {*} */ 112 | Builder.prototype.emptyResults = function (e) { 113 | if (e.message === 'Requested resource not found') return {results:[]} 114 | throw e 115 | } 116 | 117 | /** @this {*} */ 118 | Builder.prototype.emptyResult = function (e) { 119 | if (e.message === 'Requested resource not found') return {results:null} 120 | throw e 121 | } 122 | 123 | Builder.prototype.request = function (method, data) { 124 | if (this._options.logQueries) { 125 | this.logQuery(method, data) 126 | } 127 | 128 | var defer = Q.defer() 129 | 130 | if (!this._db.isFakeDynamo) { 131 | delete data._requestBuilder 132 | } 133 | 134 | var req = this._db[method](data, defer.makeNodeResolver()) 135 | var startedAt = Date.now() 136 | 137 | var retryHandler = this.getRetryHandler() 138 | var table = this._table 139 | 140 | var processingStartedAt, byteLength, requestLatencyMs 141 | 142 | // FakeDynamo doesn't return a request object 143 | if (req && req.on) { 144 | req.on('httpDone', function (res) { 145 | var now = Date.now() 146 | processingStartedAt = now 147 | requestLatencyMs = now - startedAt 148 | if (res && res.httpResponse) { 149 | byteLength = res.httpResponse.headers && res.httpResponse.headers['content-length'] 150 | } 151 | }) 152 | 153 | if (retryHandler) { 154 | req.on('retry', function (res) { 155 | retryHandler(method, table, res) 156 | }) 157 | } 158 | } 159 | 160 | return defer.then(function (output) { 161 | output.ByteLength = byteLength 162 | output.ProcessingStartedAt = processingStartedAt 163 | output.RequestLatencyMs = requestLatencyMs 164 | return output 165 | }) 166 | } 167 | 168 | Builder.prototype.logQuery = function (method, data) { 169 | var cyanBold, cyan, reset 170 | cyanBold = '\u001b[1;36m' 171 | cyan = '\u001b[0;36m' 172 | reset = '\u001b[0m' 173 | console.info(cyanBold + method + cyan) 174 | console.info(util.inspect(data, {depth: null})) 175 | console.info(reset) 176 | } 177 | 178 | Builder.prototype.prepareOutput = function (output) { 179 | return new DynamoResponse(this._tablePrefix, output, null) 180 | } 181 | 182 | Builder.prototype.convertErrors = function (context, err) { 183 | // Errors in Dynamo response are JSON objects like this: 184 | // { 185 | // "message":"Attribute found when none expected.", 186 | // "code":"ConditionalCheckFailedException", 187 | // "name":"ConditionalCheckFailedException", 188 | // "statusCode":400, 189 | // "retryable":false 190 | // } 191 | // 192 | // More at http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ErrorHandling.html 193 | // 194 | // To be more reliable, we check both err.name and err.code. 195 | // Dynamo doc specifies the value of "code", so the error object 196 | // should have "code" assigned. The node.js SDK assigns "code" 197 | // to "name". "name" is the standard attribute of javascript Error 198 | // object, so we double-check it. 199 | var data = context.data 200 | var isWrite = !!context.isWrite 201 | 202 | switch (err.code || err.name) { 203 | case 'ConditionalCheckFailedException': 204 | throw new errors.ConditionalError(data, err.message, err.requestId) 205 | case 'ProvisionedThroughputExceededException': 206 | throw new errors.ProvisioningError(data, err.message, isWrite, err.requestId) 207 | case 'ValidationException': 208 | throw new errors.ValidationError(data, err.message, isWrite, err.requestId) 209 | default: 210 | throw err 211 | } 212 | } 213 | 214 | module.exports = Builder 215 | -------------------------------------------------------------------------------- /lib/Client.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var AWS = require('aws-sdk') 3 | 4 | var AWSName = require('./common').AWSName 5 | var ConditionBuilder = require('./ConditionBuilder') 6 | var DeleteItemBuilder = require('./DeleteItemBuilder') 7 | var DescribeTableBuilder = require('./DescribeTableBuilder') 8 | var BatchGetItemBuilder = require('./BatchGetItemBuilder') 9 | var GetItemBuilder = require('./GetItemBuilder') 10 | var PutItemBuilder = require('./PutItemBuilder') 11 | var QueryBuilder = require('./QueryBuilder') 12 | var ScanBuilder = require('./ScanBuilder') 13 | var UpdateBuilder = require('./UpdateBuilder') 14 | var errors = require('./errors') 15 | 16 | /** 17 | * Creates an instance of Client which can be used to access Dynamo. 18 | * 19 | * The must-have information in 'options' are: 20 | * - region 21 | * - accessKeyId 22 | * - secretAccessKey 23 | * 24 | * If region does not present, we try to infer it from other keys, e.g., 'host', 25 | * which is mainly for the backward compatibility with some old code that use 26 | * Dynamite. 27 | * 28 | * Dynamite current supports only the 2011 version of the API. It does not 29 | * concern the user of Dynamite though, because we do not expose the low level 30 | * APIs to Dynamite users. 31 | * 32 | * @constructor 33 | * @param {{dbClient:Object, host:string, region:string, 34 | * accessKeyId:string, secretAccessKey:string, prefix: string, 35 | * logQueries: boolean, retryHandler: Function}} 36 | * options map which can be used to either configure accesss to Amazon's DynamoDB 37 | * service using a host/region, accessKeyId, and secrectAccessKey or can provide 38 | * a dbClient object which implements the interface per the AWS SDK for node.js. 39 | */ 40 | function Client(options) { 41 | this._prefix = options.prefix || '' 42 | 43 | this._commonOptions = { 44 | // whether to log out queries 45 | logQueries: !!options.logQueries 46 | } 47 | if (options.dbClient) { 48 | this.db = options.dbClient 49 | } else { 50 | if (!('region' in options)) { 51 | // If the options do not contain a 'region' key, we will try to refer 52 | // it from the values of other keys, e.g., 'host'. 53 | // 54 | // 'region' is what we need to initialize a database instance in the 55 | // AWS SDK. 56 | if ('endpoint' in options) { 57 | var endpoint = options['endpoint'] 58 | for (var i = 0; i < AWSName.REGION.length; i++) { 59 | if (endpoint.indexOf(AWSName.REGION[i]) >= 0) { 60 | options['region'] = AWSName.REGION[i] 61 | break 62 | } 63 | } 64 | } 65 | } 66 | 67 | options['apiVersion'] = AWSName.API_VERSION_2012 68 | AWS.config.update(options) 69 | this.db = new AWS.DynamoDB() 70 | } 71 | } 72 | 73 | Client.prototype.describeTable = function (table) { 74 | return new DescribeTableBuilder(this._commonOptions) 75 | .setDatabase(this.db) 76 | .setPrefix(this._prefix) 77 | .setTable(table) 78 | } 79 | 80 | Client.prototype.newQueryBuilder = function (table) { 81 | return new QueryBuilder(this._commonOptions) 82 | .setDatabase(this.db) 83 | .setPrefix(this._prefix) 84 | .setTable(table) 85 | } 86 | 87 | Client.prototype.newBatchGetBuilder = function () { 88 | return new BatchGetItemBuilder(this._commonOptions) 89 | .setDatabase(this.db) 90 | .setPrefix(this._prefix) 91 | } 92 | 93 | Client.prototype.newUpdateBuilder = function (table) { 94 | assert.equal(arguments.length, 1, "newUpdateBuilder(table) only takes table name as an arg") 95 | return new UpdateBuilder(this._commonOptions) 96 | .setDatabase(this.db) 97 | .setPrefix(this._prefix) 98 | .setTable(table) 99 | } 100 | 101 | Client.prototype.newScanBuilder = function (table) { 102 | assert.equal(arguments.length, 1, "newScanBuilder(table) only takes table name as an arg") 103 | return new ScanBuilder(this._commonOptions) 104 | .setDatabase(this.db) 105 | .setPrefix(this._prefix) 106 | .setTable(table) 107 | } 108 | 109 | Client.prototype.getItem = function (table) { 110 | assert.equal(arguments.length, 1, "getItem(table) only takes table name as an arg") 111 | return new GetItemBuilder(this._commonOptions) 112 | .setDatabase(this.db) 113 | .setPrefix(this._prefix) 114 | .setTable(table) 115 | } 116 | 117 | Client.prototype.deleteItem = function (table) { 118 | assert.equal(arguments.length, 1, "deleteItem(table) only takes table name as an arg") 119 | return new DeleteItemBuilder(this._commonOptions) 120 | .setDatabase(this.db) 121 | .setPrefix(this._prefix) 122 | .setTable(table) 123 | } 124 | 125 | Client.prototype.putItem = function (table, item) { 126 | assert.equal(arguments.length, 2, "putItem(table, item) did not have 2 arguments") 127 | return new PutItemBuilder(this._commonOptions) 128 | .setDatabase(this.db) 129 | .setPrefix(this._prefix) 130 | .setTable(table) 131 | .setItem(item) 132 | } 133 | 134 | Client.prototype.andConditions = function (conditions) { 135 | ConditionBuilder.validateConditions(conditions) 136 | return ConditionBuilder.andConditions(conditions) 137 | } 138 | 139 | Client.prototype.orConditions = function (conditions) { 140 | return ConditionBuilder.orConditions(conditions) 141 | } 142 | 143 | Client.prototype.notCondition = function (condition) { 144 | return ConditionBuilder.notCondition(condition) 145 | } 146 | 147 | Client.prototype.newConditionBuilder = function () { 148 | return new ConditionBuilder() 149 | } 150 | 151 | 152 | /** 153 | * Returns a condition builder that guarantees that an item matches an expected 154 | * state. Keys that have null, undefined, or empty string values are expected 155 | * to not be present in the item being queried. 156 | * 157 | * @param {Object} obj A map of keys to verify. 158 | * @return {ConditionBuilder} 159 | */ 160 | Client.prototype.conditions = function (obj) { 161 | var conditionBuilder = this.newConditionBuilder() 162 | for (var key in obj) { 163 | if (typeof obj[key] === 'undefined' || obj[key] === null || obj[key] === '') { 164 | conditionBuilder.expectAttributeAbsent(key) 165 | } else { 166 | conditionBuilder.expectAttributeEquals(key, obj[key]) 167 | } 168 | } 169 | return conditionBuilder 170 | } 171 | 172 | 173 | /** 174 | * Returns true if error is a Dynamo error indicating the table is throttled 175 | * @param {Error} e 176 | * @return {boolean} 177 | */ 178 | Client.isProvisioningError = function (e) { 179 | return e instanceof errors.ProvisioningError 180 | } 181 | 182 | 183 | /** 184 | * Returns true if error is a Dynamo error indicating the table is throttled 185 | * @param {Error} e 186 | * @return {boolean} 187 | */ 188 | Client.prototype.isProvisioningError = Client.isProvisioningError 189 | 190 | 191 | /** 192 | * Returns true if error is a Dynamo error indicating a validation error 193 | * @param {Error} e 194 | * @return {boolean} 195 | */ 196 | Client.isValidationError = function (e) { 197 | return e instanceof errors.ValidationError 198 | } 199 | 200 | 201 | /** 202 | * Returns true if error is a Dynamo error indicating a validation error 203 | * @param {Error} e 204 | * @return {boolean} 205 | */ 206 | Client.prototype.isValidationError = Client.isValidationError 207 | 208 | 209 | /** 210 | * Returns true if error is a Dynamo error indicating a condition wasn't met. 211 | * @param {Error} e 212 | * @return {boolean} 213 | */ 214 | Client.isConditionalError = function (e) { 215 | return e instanceof errors.ConditionalError 216 | } 217 | 218 | 219 | /** 220 | * Returns true if error is a Dynamo error indicating a condition wasn't met. 221 | * @param {Error} e 222 | * @return {boolean} 223 | */ 224 | Client.prototype.isConditionalError = Client.isConditionalError 225 | 226 | 227 | /** 228 | * Returns false if the error indicates a condition failed, otherwise the error 229 | * is re-thrown. 230 | * @param {Error} e 231 | * @return {boolean} 232 | */ 233 | Client.throwUnlessConditionalError = function (e) { 234 | if (!Client.isConditionalError(e)) throw e 235 | return false 236 | } 237 | 238 | 239 | /** 240 | * Returns false if the error indicates a condition failed, otherwise the error 241 | * is re-thrown. It is safe to use this without binding it. 242 | * @param {Error} e 243 | * @return {boolean} 244 | */ 245 | Client.prototype.throwUnlessConditionalError = Client.throwUnlessConditionalError 246 | 247 | module.exports = Client 248 | -------------------------------------------------------------------------------- /lib/DeleteItemBuilder.js: -------------------------------------------------------------------------------- 1 | var DynamoRequest = require('./DynamoRequest') 2 | var DynamoResponse = require('./DynamoResponse') 3 | var Builder = require('./Builder') 4 | 5 | /** 6 | * @param {Object} options 7 | * @constructor 8 | * @extends {Builder} 9 | */ 10 | function DeleteItemBuilder(options) { 11 | Builder.call(this, options) 12 | } 13 | require('util').inherits(DeleteItemBuilder, Builder) 14 | 15 | /** @override */ 16 | DeleteItemBuilder.prototype.prepareOutput = function (output) { 17 | output.UpdatedAttributes = null 18 | return new DynamoResponse(this.getPrefix(), output, null) 19 | } 20 | 21 | DeleteItemBuilder.prototype.execute = function () { 22 | var req = new DynamoRequest(this.getOptions()) 23 | .setTable(this._tablePrefix, this._table) 24 | .returnConsumedCapacity() 25 | .setHashKey(this._hashKey, true) 26 | .setExpected(this._conditions) 27 | .setReturnValues('ALL_OLD') 28 | 29 | if (this._rangeKey) req.setRangeKey(this._rangeKey, true) 30 | 31 | var queryData = req.build() 32 | 33 | return this.request("deleteItem", queryData) 34 | .then(this.prepareOutput.bind(this)) 35 | .failBound(this.convertErrors, null, {data: queryData, isWrite: true}) 36 | } 37 | 38 | module.exports = DeleteItemBuilder 39 | -------------------------------------------------------------------------------- /lib/DescribeTableBuilder.js: -------------------------------------------------------------------------------- 1 | var DynamoRequest = require('./DynamoRequest') 2 | var Builder = require('./Builder') 3 | 4 | /** 5 | * @param {Object} options 6 | * @constructor 7 | * @extends {Builder} 8 | */ 9 | function DescribeTableBuilder(options) { 10 | Builder.call(this, options) 11 | } 12 | require('util').inherits(DescribeTableBuilder, Builder) 13 | 14 | DescribeTableBuilder.prototype.execute = function () { 15 | var queryData = new DynamoRequest(this.getOptions()) 16 | .setTable(this._tablePrefix, this._table) 17 | .build() 18 | 19 | return this.request("describeTable", queryData) 20 | .failBound(this.convertErrors, null, {data: queryData, isWrite: false}) 21 | .clearContext() 22 | } 23 | 24 | module.exports = DescribeTableBuilder 25 | -------------------------------------------------------------------------------- /lib/DynamoRequest.js: -------------------------------------------------------------------------------- 1 | var ConditionBuilder = require('./ConditionBuilder') 2 | var UpdateExpressionBuilder = require('./UpdateExpressionBuilder') 3 | var typeUtil = require('./typeUtil') 4 | 5 | 6 | /** 7 | * @param {Object} options 8 | * @constructor 9 | */ 10 | function DynamoRequest(options) { 11 | this._options = options || {} 12 | this.data = {_requestBuilder: this} 13 | 14 | this._nameMutex = {count: 0} 15 | this._keyConditionBuilder = null 16 | this._filterBuilder = null 17 | this._conditionBuilder = null 18 | this._updateExpressionBuilder = null 19 | } 20 | 21 | DynamoRequest.prototype.setRequestItems = function (keys) { 22 | this.data.RequestItems = keys 23 | return this 24 | } 25 | 26 | 27 | DynamoRequest.prototype.setTable = function (prefix, table) { 28 | this.data.TableName = (prefix ? prefix : '') + table 29 | return this 30 | } 31 | 32 | /** 33 | * For putItem requests, the item we want to write to DynamoDB 34 | * @param {Object} item 35 | * @return {DynamoRequest} 36 | */ 37 | DynamoRequest.prototype.setItem = function (item) { 38 | if (item) { 39 | this.data.Item = typeUtil.packObjectOrArray(item) 40 | } 41 | return this 42 | } 43 | 44 | DynamoRequest.prototype.setParallelScan = function (segment, totalSegments) { 45 | if (typeof segment != 'undefined' && totalSegments) { 46 | this.data.Segment = segment 47 | this.data.TotalSegments = totalSegments 48 | } 49 | return this 50 | } 51 | 52 | /** 53 | * @return {DynamoRequest} 54 | */ 55 | DynamoRequest.prototype.returnConsumedCapacity = function () { 56 | this.data.ReturnConsumedCapacity = 'TOTAL' 57 | return this 58 | } 59 | 60 | 61 | /** 62 | * @param {!Object} attributeUpdates 63 | * @return {DynamoRequest} 64 | */ 65 | DynamoRequest.prototype.setUpdates = function (attributeUpdates) { 66 | if (attributeUpdates) { 67 | this._updateExpressionBuilder = UpdateExpressionBuilder.populateUpdateExpression( 68 | this.data, attributeUpdates, this._nameMutex) 69 | } 70 | return this 71 | } 72 | 73 | DynamoRequest.prototype.setReturnValues = function (returnValues) { 74 | if (returnValues) { 75 | this.data.ReturnValues = returnValues 76 | } 77 | return this 78 | } 79 | 80 | /** 81 | * @param {Array.} conditions An array of conditions, possibly null to indicate 82 | * no conditions. 83 | * @return {DynamoRequest} 84 | */ 85 | DynamoRequest.prototype.setKeyConditions = function (conditions) { 86 | if (conditions) { 87 | this._keyConditionBuilder = ConditionBuilder.populateExpressionField( 88 | this.data, 'KeyConditionExpression', conditions, this._nameMutex) 89 | } 90 | return this 91 | } 92 | 93 | /** 94 | * @param {Array.} conditions An array of conditions, possibly null to indicate 95 | * no conditions. 96 | * @return {DynamoRequest} 97 | */ 98 | DynamoRequest.prototype.setQueryFilter = function (conditions) { 99 | if (conditions) { 100 | this._filterBuilder = ConditionBuilder.populateExpressionField( 101 | this.data, 'FilterExpression', conditions, this._nameMutex) 102 | } 103 | return this 104 | } 105 | 106 | /** 107 | * @param {Array.} conditions An array of conditions, possibly null to indicate 108 | * no conditions. 109 | * @return {DynamoRequest} 110 | */ 111 | DynamoRequest.prototype.setScanFilter = function (conditions) { 112 | if (conditions) { 113 | this._filterBuilder = ConditionBuilder.populateExpressionField( 114 | this.data, 'FilterExpression', conditions, this._nameMutex) 115 | } 116 | return this 117 | } 118 | 119 | /** 120 | * @param {Array.} conditions An array of conditions, possibly null to indicate 121 | * no conditions. 122 | * @return {DynamoRequest} 123 | */ 124 | DynamoRequest.prototype.setExpected = function (conditions) { 125 | if (conditions) { 126 | this._conditionBuilder = ConditionBuilder.populateExpressionField( 127 | this.data, 'ConditionExpression', conditions, this._nameMutex) 128 | } 129 | return this 130 | } 131 | 132 | DynamoRequest.prototype.setConsistent = function (isConsistent) { 133 | this.data.ConsistentRead = !!isConsistent 134 | return this 135 | } 136 | 137 | /** 138 | * For query and scan requests, the number of items to iterate over (before the filter) 139 | * @param {number} limit 140 | * @return {DynamoRequest} 141 | */ 142 | DynamoRequest.prototype.setLimit = function (limit) { 143 | if (limit) { 144 | this.data.Limit = limit 145 | } 146 | return this 147 | } 148 | 149 | DynamoRequest.prototype.setHashKey = function (key) { 150 | this.data.Key = {} 151 | if (!key) throw new Error('A hash key is required') 152 | 153 | this.data.Key[key.name] = typeUtil.valueToObject(key.val) 154 | 155 | return this 156 | } 157 | 158 | DynamoRequest.prototype.setRangeKey = function (key) { 159 | if (!this.data.Key) throw new Error('The hash key must be set first') 160 | if (!key) throw new Error('A range key is required') 161 | 162 | this.data.Key[key.name] = typeUtil.valueToObject(key.val) 163 | 164 | return this 165 | } 166 | 167 | DynamoRequest.prototype.setStartKey = function (key) { 168 | if (key) { 169 | this.data.ExclusiveStartKey = typeUtil.packObjectOrArray(key) 170 | } 171 | return this 172 | } 173 | 174 | DynamoRequest.prototype.selectAttributes = function (attributes) { 175 | if (attributes) { 176 | if (!this.data.ExpressionAttributeNames) { 177 | this.data.ExpressionAttributeNames = {} 178 | } 179 | typeUtil.extendAttributeNames(this.data.ExpressionAttributeNames, typeUtil.buildAttributeNames(attributes)) 180 | 181 | this.data.ProjectionExpression = attributes.map(function (attr) { 182 | return typeUtil.getAttributeAlias(attr) 183 | }).join(',') 184 | } 185 | return this 186 | } 187 | 188 | DynamoRequest.prototype.setIndexName = function (indexName) { 189 | if(indexName) { 190 | this.data.IndexName = indexName 191 | } 192 | return this 193 | } 194 | 195 | 196 | DynamoRequest.prototype.setBatchTableAttributes = function (tablePrefix, attributes) { 197 | this._setPerTableValue(tablePrefix, attributes, 'AttributesToGet') 198 | return this 199 | } 200 | 201 | 202 | DynamoRequest.prototype.setBatchTableConsistent = function (tablePrefix, isConsistentValues) { 203 | this._setPerTableValue(tablePrefix, isConsistentValues, 'ConsistentRead') 204 | return this 205 | } 206 | 207 | 208 | DynamoRequest.prototype._setPerTableValue = function (tablePrefix, values, propertyName) { 209 | if (values) { 210 | if (!this.data.RequestItems) this.data.RequestItems = {} 211 | for (var key in values) { 212 | var tableName = (tablePrefix || '') + key 213 | if (!this.data.RequestItems[tableName]) this.data.RequestItems[tableName] = {} 214 | this.data.RequestItems[tableName][propertyName] = values[key] 215 | } 216 | } 217 | } 218 | 219 | 220 | /** 221 | * Takes a map items to Dynamo request format. requestItems is an object containing an array 222 | * of Primary Keys for each table. For example: 223 | * 224 | * { userTable : [{userId: '1234', column: '@'} ... ]} 225 | */ 226 | DynamoRequest.prototype.setBatchRequestItems = function (tablePrefix, requestItems) { 227 | if (!this.data.RequestItems) this.data.RequestItems = {} 228 | for (var tableName in requestItems) { 229 | if (!Array.isArray(requestItems[tableName])) { 230 | throw new Error('RequestedItems not an array, for table=' + tableName) 231 | } 232 | var tableNameWithPrefix = (tablePrefix || '') + tableName 233 | if (!this.data.RequestItems[tableNameWithPrefix]) this.data.RequestItems[tableNameWithPrefix] = {} 234 | if (!this.data.RequestItems[tableNameWithPrefix].Keys) this.data.RequestItems[tableNameWithPrefix].Keys = [] 235 | for (var i = 0; i < requestItems[tableName].length; i++) { 236 | var keys = requestItems[tableName][i] 237 | var dynamoKeys = {} 238 | for (var key in keys) dynamoKeys[key] = typeUtil.valueToObject(keys[key]) 239 | this.data.RequestItems[tableNameWithPrefix].Keys.push(dynamoKeys) 240 | } 241 | } 242 | return this 243 | } 244 | 245 | 246 | DynamoRequest.prototype.scanForward = function (isForward) { 247 | this.data.ScanIndexForward = typeof isForward === 'undefined' || isForward 248 | return this 249 | } 250 | 251 | DynamoRequest.prototype.getCount = function () { 252 | this.data.Select = "COUNT" 253 | return this 254 | } 255 | 256 | DynamoRequest.prototype.build = function () { 257 | // Dynamo doesn't like it when alias objects are empty. 258 | if (this.data.ExpressionAttributeNames && 259 | !Object.keys(this.data.ExpressionAttributeNames).length) { 260 | delete this.data.ExpressionAttributeNames 261 | } 262 | 263 | if (this.data.ExpressionAttributeValues && 264 | !Object.keys(this.data.ExpressionAttributeValues).length) { 265 | delete this.data.ExpressionAttributeValues 266 | } 267 | 268 | // Dynamo doesn't like it when conditions are empty 269 | if (!this.data.KeyConditionExpression) { 270 | delete this.data.KeyConditionExpression 271 | } 272 | 273 | if (!this.data.FilterExpression) { 274 | delete this.data.FilterExpression 275 | } 276 | 277 | if (!this.data.ConditionExpression) { 278 | delete this.data.ConditionExpression 279 | } 280 | 281 | if (!this.data.UpdateExpression) { 282 | delete this.data.UpdateExpression 283 | } 284 | 285 | return this.data 286 | } 287 | 288 | module.exports = DynamoRequest 289 | -------------------------------------------------------------------------------- /lib/DynamoResponse.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013. The Obvious Corporation. 2 | 3 | var typeUtil = require('./typeUtil') 4 | 5 | /** 6 | * @param {?string} tablePrefix 7 | * @param {Object} output Response JSON 8 | * @param {?function(Object, number?): Promise} repeatWithStartKey Make the same query with a different start key. 9 | * Only valid for Query/Scan results. 10 | * @constructor 11 | */ 12 | var DynamoResponse = function (tablePrefix, output, repeatWithStartKey) { 13 | /** @private {?function(Object, number?): Promise} */ 14 | this._repeatWithStartKey = repeatWithStartKey 15 | 16 | this.ConsumedCapacityUnits = output.ConsumedCapacityUnits 17 | this.Count = output.Count 18 | this.ProcessingStartedAt = output.ProcessingStartedAt 19 | this.RequestLatencyMs = output.RequestLatencyMs 20 | this.ByteLength = output.ByteLength 21 | 22 | this.LastEvaluatedKey = undefined 23 | if (output.LastEvaluatedKey) { 24 | this.LastEvaluatedKey = typeUtil.unpackObjectOrArray(output.LastEvaluatedKey) 25 | } 26 | 27 | // For batchGet 28 | this.UnprocessedKeys = undefined 29 | 30 | var table 31 | if (output.UnprocessedKeys) { 32 | var unprocessed = {} 33 | for (table in output.UnprocessedKeys) { 34 | unprocessed[table] = typeUtil.unpackObjectOrArray(output.UnprocessedKeys[table].Keys) 35 | } 36 | this.UnprocessedKeys = unprocessed 37 | } 38 | 39 | this.ConsumedCapacity = undefined 40 | if (output.ConsumedCapacity) { 41 | if (!Array.isArray(output.ConsumedCapacity)) { 42 | output.ConsumedCapacity = [output.ConsumedCapacity] 43 | } 44 | var capacity = {} 45 | output.ConsumedCapacity.forEach(function (outputCapacity) { 46 | capacity[getOriginalTableName(tablePrefix, outputCapacity.TableName)] = outputCapacity.CapacityUnits 47 | }) 48 | this.ConsumedCapacity = capacity 49 | } 50 | 51 | this.result = undefined 52 | 53 | // for Query and Scan, 'result' is {Array.} 54 | if (output.Items) { 55 | this.result = typeUtil.unpackObjectOrArray(output.Items) 56 | 57 | // for GetItem, 'result' is {Object} 58 | } else if (output.Item) { 59 | this.result = typeUtil.unpackObjectOrArray(output.Item) 60 | 61 | // for DeleteItem, PutItem and UpdateItem, 'result' is {Object} 62 | } else if (output.UpdatedAttributes || output.Attributes) { 63 | this.result = typeUtil.unpackObjectOrArray(output.UpdatedAttributes) 64 | this.previous = typeUtil.unpackObjectOrArray(output.Attributes) 65 | 66 | // for BatchGetItem, 'result' is {Object.} 67 | } else if (output.Responses) { 68 | this.result = {} 69 | for (table in output.Responses) { 70 | var origTableName = getOriginalTableName(tablePrefix, table) 71 | this.result[origTableName] = typeUtil.unpackObjectOrArray(output.Responses[table]) 72 | } 73 | } 74 | return this 75 | } 76 | 77 | /** 78 | * @return {boolean} If the query or scan has more results. 79 | */ 80 | DynamoResponse.prototype.hasNext = function () { 81 | return !!(this.LastEvaluatedKey && this._repeatWithStartKey) 82 | } 83 | 84 | /** 85 | * @param {?number} opt_limit The number of items to check 86 | * @return {Promise.} 87 | */ 88 | DynamoResponse.prototype.next = function (opt_limit) { 89 | if (!this.hasNext()) throw new Error('No more results') 90 | return this._repeatWithStartKey(/** @type {Object} */ (this.LastEvaluatedKey), opt_limit) 91 | } 92 | 93 | function getOriginalTableName (tablePrefix, tableName) { 94 | return tablePrefix ? tableName.substr(tablePrefix.length) : tableName 95 | } 96 | 97 | module.exports = DynamoResponse 98 | -------------------------------------------------------------------------------- /lib/GetItemBuilder.js: -------------------------------------------------------------------------------- 1 | var DynamoRequest = require('./DynamoRequest') 2 | var Builder = require('./Builder') 3 | 4 | /** 5 | * @param {Object} options 6 | * @constructor 7 | * @extends {Builder} 8 | */ 9 | function GetItemBuilder(options) { 10 | Builder.call(this, options) 11 | } 12 | require('util').inherits(GetItemBuilder, Builder) 13 | 14 | GetItemBuilder.prototype.execute = function () { 15 | var req = new DynamoRequest(this.getOptions()) 16 | .setTable(this._tablePrefix, this._table) 17 | .returnConsumedCapacity() 18 | .setConsistent(this._isConsistent) 19 | .setHashKey(this._hashKey, true) 20 | .selectAttributes(this._attributes) 21 | 22 | if (this._rangeKey) req.setRangeKey(this._rangeKey, true) 23 | 24 | var queryData = req.build() 25 | 26 | return this.request("getItem", queryData) 27 | .then(this.prepareOutput.bind(this)) 28 | .fail(this.emptyResult) 29 | .failBound(this.convertErrors, null, {data: queryData, isWrite: false}) 30 | } 31 | 32 | module.exports = GetItemBuilder 33 | -------------------------------------------------------------------------------- /lib/PutItemBuilder.js: -------------------------------------------------------------------------------- 1 | var typ = require('typ') 2 | var DynamoRequest = require('./DynamoRequest') 3 | var DynamoResponse = require('./DynamoResponse') 4 | var Builder = require('./Builder') 5 | var typeUtil = require('./typeUtil') 6 | var errors = require('./errors') 7 | 8 | /** 9 | * @param {Object} options 10 | * @constructor 11 | * @extends {Builder} 12 | */ 13 | function PutItemBuilder(options) { 14 | Builder.call(this, options) 15 | 16 | /** @private {string} */ 17 | this._returnValues = PutItemBuilder.RETURN_VALUES.ALL_OLD 18 | } 19 | require('util').inherits(PutItemBuilder, Builder) 20 | 21 | PutItemBuilder.RETURN_VALUES = { 22 | NONE: 'NONE', 23 | ALL_OLD: 'ALL_OLD', 24 | UPDATED_OLD: 'UPDATED_OLD', 25 | ALL_NEW: 'ALL_NEW', 26 | UPDATED_NEW: 'UPDATED_NEW' 27 | } 28 | 29 | PutItemBuilder.prototype.setReturnValues = function (val) { 30 | if (!PutItemBuilder.RETURN_VALUES[val]) { 31 | throw new errors.InvalidReturnValuesError(val) 32 | } 33 | 34 | this._returnValues = val 35 | return this 36 | } 37 | 38 | PutItemBuilder.prototype.setItem = function (item) { 39 | for (var key in item) { 40 | if (typ.isNullish(item[key])) { 41 | throw new Error("Field '" + key + "' on item must not be null or undefined") 42 | } 43 | } 44 | this._item = item 45 | return this 46 | } 47 | 48 | /** @override */ 49 | PutItemBuilder.prototype.prepareOutput = function (output) { 50 | if (this._returnValues !== 'NONE') { 51 | output.UpdatedAttributes = typeUtil.packObjectOrArray(this._item) 52 | } 53 | return new DynamoResponse(this.getPrefix(), output, null) 54 | } 55 | 56 | PutItemBuilder.prototype.execute = function () { 57 | var queryData = new DynamoRequest(this.getOptions()) 58 | .setTable(this._tablePrefix, this._table) 59 | .returnConsumedCapacity() 60 | .setItem(this._item) 61 | .setExpected(this._conditions) 62 | .setReturnValues(this._returnValues) 63 | .build() 64 | 65 | return this.request("putItem", queryData) 66 | .then(this.prepareOutput.bind(this)) 67 | .failBound(this.convertErrors, null, {data: queryData, isWrite: true}) 68 | } 69 | 70 | module.exports = PutItemBuilder 71 | -------------------------------------------------------------------------------- /lib/QueryBuilder.js: -------------------------------------------------------------------------------- 1 | var typ = require('typ') 2 | var ConditionBuilder = require('./ConditionBuilder') 3 | var DynamoRequest = require('./DynamoRequest') 4 | var DynamoResponse = require('./DynamoResponse') 5 | var Builder = require('./Builder') 6 | var util = require('util') 7 | var IndexNotExistError = require('./errors').IndexNotExistError 8 | 9 | /** 10 | * @param {Object} options 11 | * @constructor 12 | * @extends {Builder} 13 | */ 14 | function QueryBuilder(options) { 15 | Builder.call(this, options) 16 | 17 | /** @private {!ConditionBuilder} */ 18 | this._keyConditions = new ConditionBuilder() 19 | } 20 | util.inherits(QueryBuilder, Builder) 21 | 22 | /** 23 | * If this query runs on a local index or global index, set a 24 | * function that can generate an index name based on query 25 | * conditions. 26 | * 27 | * @param {function(string, string): string} fn The generator function 28 | */ 29 | QueryBuilder.prototype.setIndexNameGenerator = function (fn) { 30 | this._indexNameGenerator = fn 31 | return this 32 | } 33 | 34 | QueryBuilder.prototype.setHashKey = function (name, val) { 35 | this._hashKeyName = name 36 | this._keyConditions.filterAttributeEquals(name, val) 37 | return this 38 | } 39 | 40 | QueryBuilder.prototype.setIndexRangeKeyWithoutCondition = function (name) { 41 | this._rangeKeyName = name 42 | return this 43 | } 44 | 45 | QueryBuilder.prototype.indexBeginsWith = function (name, prefix) { 46 | this._rangeKeyName = name 47 | this._keyConditions.filterAttributeBeginsWith(name, prefix) 48 | return this 49 | } 50 | 51 | QueryBuilder.prototype.indexEqual = 52 | QueryBuilder.prototype.indexEquals = function (name, val) { 53 | this._rangeKeyName = name 54 | this._keyConditions.filterAttributeEquals(name, val) 55 | return this 56 | } 57 | 58 | QueryBuilder.prototype.indexLessThanEqual = 59 | QueryBuilder.prototype.indexLessThanEquals = function (name, val) { 60 | this._rangeKeyName = name 61 | this._keyConditions.filterAttributeLessThanEqual(name, val) 62 | return this 63 | } 64 | 65 | QueryBuilder.prototype.indexLessThan = function (name, val) { 66 | this._rangeKeyName = name 67 | this._keyConditions.filterAttributeLessThan(name, val) 68 | return this 69 | } 70 | 71 | QueryBuilder.prototype.indexGreaterThanEqual = 72 | QueryBuilder.prototype.indexGreaterThanEquals = function (name, val) { 73 | this._rangeKeyName = name 74 | this._keyConditions.filterAttributeGreaterThanEqual(name, val) 75 | return this 76 | } 77 | 78 | QueryBuilder.prototype.indexGreaterThan = function (name, val) { 79 | this._rangeKeyName = name 80 | this._keyConditions.filterAttributeGreaterThan(name, val) 81 | return this 82 | } 83 | 84 | QueryBuilder.prototype.indexBetween = function (name, val1, val2) { 85 | this._rangeKeyName = name 86 | this._keyConditions.filterAttributeBetween(name, val1, val2) 87 | return this 88 | } 89 | 90 | QueryBuilder.prototype.setStartKey = function (key) { 91 | this._startKey = key 92 | return this 93 | } 94 | 95 | /** 96 | * Set the index name of this query. 97 | * 98 | * @param {string} indexName 99 | */ 100 | QueryBuilder.prototype.setIndexName = function (indexName) { 101 | this._indexName = indexName 102 | return this 103 | } 104 | 105 | /** @override */ 106 | QueryBuilder.prototype.prepareOutput = function (output) { 107 | return new DynamoResponse( 108 | this.getPrefix(), output, this._repeatWithStartKey.bind(this)) 109 | } 110 | 111 | /** 112 | * @param {Object} nextKey 113 | * @param {?number} opt_limit The number of items to check 114 | * @return {Q.Promise.} 115 | * @private 116 | */ 117 | QueryBuilder.prototype._repeatWithStartKey = function (nextKey, opt_limit) { 118 | if (!typ.isNullish(opt_limit)) { 119 | this.setLimit(opt_limit) 120 | } 121 | return this.setStartKey(nextKey).execute() 122 | } 123 | 124 | QueryBuilder.prototype.execute = function () { 125 | var query = new DynamoRequest(this.getOptions()) 126 | .setTable(this._tablePrefix, this._table) 127 | .returnConsumedCapacity() 128 | .setQueryFilter(this._filters) 129 | .setConsistent(this._isConsistent) 130 | .setIndexName(this._indexName) 131 | .setKeyConditions([this._keyConditions]) 132 | .setStartKey(this._startKey) 133 | .selectAttributes(this._attributes, true) 134 | .scanForward(this._shouldScanForward) 135 | .setLimit(this._limit) 136 | 137 | if (this._isCount) query.getCount() 138 | 139 | if (this._rangeKeyCondition) { 140 | query.setRangeKey.apply(query, this._rangeKeyCondition) 141 | } 142 | 143 | if (this._indexNameGenerator) { 144 | var indexName = this._indexNameGenerator(this._hashKeyName, this._rangeKeyName) 145 | if (!indexName) { 146 | throw new IndexNotExistError(this._hashKeyName, this._rangeKeyName) 147 | } 148 | query.setIndexName(indexName) 149 | } 150 | 151 | var queryData = query.build() 152 | 153 | return this.request('query', queryData) 154 | .then(this.prepareOutput.bind(this)) 155 | .fail(this.emptyResults) 156 | .failBound(this.convertErrors, null, {data: queryData, isWrite: false}) 157 | } 158 | 159 | module.exports = QueryBuilder 160 | -------------------------------------------------------------------------------- /lib/ScanBuilder.js: -------------------------------------------------------------------------------- 1 | var typ = require('typ') 2 | var DynamoRequest = require('./DynamoRequest') 3 | var DynamoResponse = require('./DynamoResponse') 4 | var Builder = require('./Builder') 5 | var IndexNotExistError = require('./errors').IndexNotExistError 6 | 7 | /** 8 | * @param {Object} options 9 | * @constructor 10 | * @extends {Builder} 11 | */ 12 | function ScanBuilder(options) { 13 | Builder.call(this, options) 14 | } 15 | require('util').inherits(ScanBuilder, Builder) 16 | 17 | /** 18 | * If this scan runs on a local index or global index, set a 19 | * function that can generate an index name based on query 20 | * conditions. 21 | * 22 | * @param {function(string, string): string} fn The generator function 23 | */ 24 | ScanBuilder.prototype.setIndexNameGenerator = function (fn) { 25 | this._indexNameGenerator = fn 26 | return this 27 | } 28 | 29 | 30 | ScanBuilder.prototype.setStartKey = function (key) { 31 | this._startKey = key 32 | return this 33 | } 34 | 35 | /** 36 | * @param {number} segment 37 | * @param {number} totalSegments 38 | * @return {ScanBuilder} 39 | */ 40 | ScanBuilder.prototype.setParallelScan = function (segment, totalSegments) { 41 | this._segment = segment 42 | this._totalSegments = totalSegments 43 | return this 44 | } 45 | 46 | /** @override */ 47 | ScanBuilder.prototype.prepareOutput = function (output) { 48 | return new DynamoResponse( 49 | this.getPrefix(), output, this._repeatWithStartKey.bind(this)) 50 | } 51 | 52 | /** 53 | * @param {Object} nextKey 54 | * @param {?number} opt_limit The number of items to check 55 | * @return {Q.Promise.} 56 | * @private 57 | */ 58 | ScanBuilder.prototype._repeatWithStartKey = function (nextKey, opt_limit) { 59 | if (!typ.isNullish(opt_limit)) { 60 | this.setLimit(opt_limit) 61 | } 62 | return this.setStartKey(nextKey).execute() 63 | } 64 | 65 | ScanBuilder.prototype.execute = function () { 66 | var query = new DynamoRequest(this.getOptions()) 67 | .setTable(this._tablePrefix, this._table) 68 | .returnConsumedCapacity() 69 | .setScanFilter(this._filters) 70 | .setLimit(this._limit) 71 | .setStartKey(this._startKey) 72 | .selectAttributes(this._attributes) 73 | .setParallelScan(this._segment, this._totalSegments) 74 | 75 | if (this._indexNameGenerator) { 76 | var rangeKeyName = this._rangeKey ? this._rangeKey.name : '' 77 | var indexName = this._indexNameGenerator(this._hashKey.name, rangeKeyName) 78 | if (!indexName) { 79 | throw new IndexNotExistError(this._hashKey.name, this._rangeKey.name) 80 | } 81 | query.setIndexName(indexName) 82 | } 83 | 84 | var queryData = query.build() 85 | 86 | return this.request("scan", queryData) 87 | .then(this.prepareOutput.bind(this)) 88 | .fail(this.emptyResults) 89 | .failBound(this.convertErrors, null, {data: queryData, isWrite: false}) 90 | } 91 | 92 | module.exports = ScanBuilder 93 | -------------------------------------------------------------------------------- /lib/UpdateBuilder.js: -------------------------------------------------------------------------------- 1 | var typ = require('typ') 2 | var util = require('util') 3 | var DynamoRequest = require('./DynamoRequest') 4 | var DynamoResponse = require('./DynamoResponse') 5 | var Builder = require('./Builder') 6 | var localUpdater = require('./localUpdater') 7 | var typeUtil = require('./typeUtil') 8 | var errors = require('./errors') 9 | 10 | /** 11 | * @param {Object} options 12 | * @constructor 13 | * @extends {Builder} 14 | */ 15 | function UpdateBuilder(options) { 16 | Builder.call(this, options) 17 | 18 | /** @private {!Object.} */ 19 | this._attributeUpdates = {} 20 | 21 | /** @private {boolean} */ 22 | this._enabledUpsert = false 23 | 24 | /** @private {string} */ 25 | this._returnValues = UpdateBuilder.RETURN_VALUES.ALL_OLD 26 | 27 | this._uniqueName = '' 28 | } 29 | util.inherits(UpdateBuilder, Builder) 30 | 31 | UpdateBuilder.RETURN_VALUES = { 32 | NONE: 'NONE', 33 | ALL_OLD: 'ALL_OLD', 34 | UPDATED_OLD: 'UPDATED_OLD', 35 | ALL_NEW: 'ALL_NEW', 36 | UPDATED_NEW: 'UPDATED_NEW' 37 | } 38 | 39 | UpdateBuilder.prototype.enableUpsert = function () { 40 | this._enabledUpsert = true 41 | return this 42 | } 43 | 44 | UpdateBuilder.prototype.setReturnValues = function (val) { 45 | if (!UpdateBuilder.RETURN_VALUES[val]) { 46 | throw new errors.InvalidReturnValuesError(val) 47 | } 48 | 49 | this._returnValues = val 50 | return this 51 | } 52 | 53 | /** 54 | * @param {string} key 55 | * @param {boolean|number|string|Array.|Array.} val 56 | */ 57 | UpdateBuilder.prototype.putAttribute = function (key, val) { 58 | if (typ.isNullish(key)) throw new Error("Key must be defined") 59 | if (typ.isNullish(val)) throw new Error("Val must be defined") 60 | this._attributeUpdates[key] = { 61 | Value: typeUtil.valueToObject(val), 62 | Action: 'PUT' 63 | } 64 | return this 65 | } 66 | 67 | /** 68 | * @param {string} key 69 | * @param {number|string|Array.|Array.} val 70 | */ 71 | UpdateBuilder.prototype.addToAttribute = function (key, val) { 72 | if (typ.isNullish(key)) throw new Error("Key must be defined") 73 | if (typ.isNullish(val)) throw new Error("Val must be defined") 74 | this._attributeUpdates[key] = { 75 | Value: typeUtil.valueToObject(val), 76 | Action: 'ADD' 77 | } 78 | return this 79 | } 80 | 81 | /** 82 | * @param {string} key 83 | * @param {number|string|Array.|Array.} val 84 | */ 85 | UpdateBuilder.prototype.deleteFromAttribute = function (key, val) { 86 | if (typ.isNullish(key)) throw new Error("Key must be defined") 87 | if (typ.isNullish(val)) throw new Error("Val must be defined") 88 | 89 | this._attributeUpdates[key] = { 90 | Value: typeUtil.valueToObject(val), 91 | Action: 'DELETE' 92 | } 93 | return this 94 | } 95 | 96 | UpdateBuilder.prototype.deleteAttribute = function (key) { 97 | if (typ.isNullish(key)) throw new Error("Key must be defined") 98 | this._attributeUpdates[key] = { 99 | Action: 'DELETE' 100 | } 101 | return this 102 | } 103 | 104 | /** @override */ 105 | UpdateBuilder.prototype.prepareOutput = function (output) { 106 | if (this._returnValues !== 'NONE') { 107 | var attributes = output.Attributes 108 | if (!attributes) { 109 | attributes = {} 110 | attributes[this._hashKey.name] = typeUtil.valueToObject(this._hashKey.val) 111 | if (this._rangeKey) { 112 | attributes[this._rangeKey.name] = typeUtil.valueToObject(this._rangeKey.val) 113 | } 114 | } 115 | 116 | output.UpdatedAttributes = localUpdater.update(attributes, this._attributeUpdates) 117 | } 118 | return new DynamoResponse(this.getPrefix(), output, null) 119 | } 120 | 121 | UpdateBuilder.prototype.execute = function () { 122 | var req = new DynamoRequest(this.getOptions()) 123 | .setTable(this._tablePrefix, this._table) 124 | .returnConsumedCapacity() 125 | .setHashKey(this._hashKey, true) 126 | .setUpdates(this._attributeUpdates) 127 | .setExpected(this._conditions) 128 | .setReturnValues(this._returnValues) 129 | 130 | if (this._rangeKey) req.setRangeKey(this._rangeKey, true) 131 | 132 | var queryData = req.build() 133 | 134 | if ((!this._conditions || !this._conditions.length) && !this._enabledUpsert) { 135 | console.warn("Update issued without conditions or .enableUpsert() called") 136 | console.trace() 137 | } 138 | return this.request("updateItem", queryData) 139 | .then(this.prepareOutput.bind(this)) 140 | .failBound(this.convertErrors, null, {data: queryData, isWrite: true}) 141 | } 142 | 143 | module.exports = UpdateBuilder 144 | -------------------------------------------------------------------------------- /lib/UpdateExpressionBuilder.js: -------------------------------------------------------------------------------- 1 | var typeUtil = require('./typeUtil') 2 | 3 | // The UpdateExpression API has different names for all the action types, but they're 4 | // semantically the same as the old actions. 5 | function getActionName(attr) { 6 | switch (attr.Action) { 7 | case 'PUT': 8 | return 'SET' 9 | case 'ADD': 10 | return 'ADD' 11 | case 'DELETE': 12 | return attr.Value ? 'DELETE' : 'REMOVE' 13 | default: 14 | throw new Error('Unrecognized action ' + attr.Action) 15 | } 16 | } 17 | 18 | /** 19 | * Translates old AttributeValueUpdate format into the new UpdateExpression format. 20 | * 21 | * @param {Object} attributes 22 | * @constructor 23 | */ 24 | function UpdateExpressionBuilder(attributes) { 25 | this._attributes = attributes 26 | this._uniqueName = '' 27 | } 28 | 29 | /** 30 | * @param {Object} data 31 | * @param {Array} attributes 32 | * @param {{count: number}} nameMutex 33 | * @return {UpdateExpressionBuilder} 34 | */ 35 | UpdateExpressionBuilder.populateUpdateExpression = function (data, attributes, nameMutex) { 36 | var builder = new UpdateExpressionBuilder(attributes) 37 | builder._assignUniqueNames(nameMutex) 38 | 39 | if (!data.ExpressionAttributeNames) { 40 | data.ExpressionAttributeNames = {} 41 | } 42 | typeUtil.extendAttributeNames(data.ExpressionAttributeNames, builder.buildAttributeNames()) 43 | 44 | if (!data.ExpressionAttributeValues) { 45 | data.ExpressionAttributeValues = {} 46 | } 47 | typeUtil.extendAttributeValues(data.ExpressionAttributeValues, builder.buildAttributeValues()) 48 | 49 | data.UpdateExpression = builder.buildExpression() 50 | return builder 51 | } 52 | 53 | /** 54 | * @param {Object} nameMutex 55 | */ 56 | UpdateExpressionBuilder.prototype._assignUniqueNames = function (nameMutex) { 57 | if (!nameMutex.count) { 58 | nameMutex.count = 1 59 | } 60 | this._uniqueName = 'U' + nameMutex.count++ 61 | } 62 | 63 | /** @return {Object} */ 64 | UpdateExpressionBuilder.prototype.buildAttributeNames = function () { 65 | return typeUtil.buildAttributeNames(Object.keys(this._attributes)) 66 | } 67 | 68 | /** @return {Object} */ 69 | UpdateExpressionBuilder.prototype.buildAttributeValues = function () { 70 | var result = {} 71 | Object.keys(this._attributes).map(function (key) { 72 | var attr = this._attributes[key] 73 | var value = attr.Value 74 | if (value) { 75 | result[this._getValueAlias(key)] = value 76 | } 77 | }, this) 78 | return result 79 | } 80 | 81 | /** 82 | * @see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ExpressionPlaceholders.html#ExpressionAttributeValues 83 | */ 84 | UpdateExpressionBuilder.prototype._getValueAlias = function (key) { 85 | if (!this._uniqueName) throw new Error('Names have not been assigned yet') 86 | return ':V' + this._uniqueName + 'X' + key 87 | } 88 | 89 | /** 90 | * @return {string} String suitable for UpdateExpression 91 | * @see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.Modifying.html 92 | */ 93 | UpdateExpressionBuilder.prototype.buildExpression = function () { 94 | var keysByAction = {} 95 | Object.keys(this._attributes).map(function (key) { 96 | var attr = this._attributes[key] 97 | var action = getActionName(attr) 98 | if (!keysByAction[action]) { 99 | keysByAction[action] = [] 100 | } 101 | keysByAction[action].push(key) 102 | }, this) 103 | 104 | var groups = [] 105 | Object.keys(keysByAction).map(function (action) { 106 | var keys = keysByAction[action] 107 | groups.push( 108 | action + ' ' + 109 | keys.map(function (key) { 110 | var attrAlias = typeUtil.getAttributeAlias(key) 111 | var valueAlias = this._getValueAlias(key) 112 | if (action == 'REMOVE') { 113 | return attrAlias 114 | } else if (action == 'SET') { 115 | return attrAlias + ' = ' + valueAlias 116 | } else if (action == 'ADD' || action == 'DELETE') { 117 | return attrAlias + ' ' + valueAlias 118 | } else { 119 | throw new Error('Unrecognized action ' + action) 120 | } 121 | }, this).join(',')) 122 | }, this) 123 | 124 | return groups.join(' ') 125 | } 126 | 127 | module.exports = UpdateExpressionBuilder 128 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | var AWSName = { 2 | REGION: ["us-east-1", 3 | "us-west-1", 4 | "us-west-2", 5 | "eu-west-1", 6 | "ap-northeast-1", 7 | "ap-southeast-1", 8 | "ap-southeast-2", 9 | "sa-east-1"], 10 | API_VERSION_2011: "2011-12-05", 11 | API_VERSION_2012: "2012-08-10" 12 | } 13 | 14 | module.exports = { 15 | AWSName: AWSName 16 | } 17 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013. The Obvious Corporation. 2 | 3 | var util = require('util') 4 | 5 | /** 6 | * @param {Object} data 7 | * @param {string} msg 8 | * @param {string} requestId 9 | * @constructor 10 | * @extends {Error} 11 | */ 12 | function ConditionalError(data, msg, requestId) { 13 | Error.captureStackTrace(this) 14 | this.data = data || {} 15 | this.message = 'The conditional request failed' 16 | this.details = msg 17 | this.table = data.TableName || 'unknown' 18 | this.requestId = requestId 19 | } 20 | util.inherits(ConditionalError, Error) 21 | ConditionalError.prototype.type = 'ConditionalError' 22 | ConditionalError.prototype.name = 'ConditionalError' 23 | 24 | 25 | /** 26 | * @param {Object} data 27 | * @param {string} msg 28 | * @param {boolean} isWrite 29 | * @param {string} requestId 30 | * @constructor 31 | * @extends {Error} 32 | */ 33 | function ProvisioningError(data, msg, isWrite, requestId) { 34 | Error.captureStackTrace(this) 35 | this.data = data || {} 36 | this.message = 'The level of configured provisioned throughput for the table was exceeded' 37 | this.details = msg 38 | this.table = data.TableName || 'unknown' 39 | this.isWrite = isWrite 40 | this.requestId = requestId 41 | } 42 | util.inherits(ProvisioningError, Error) 43 | ProvisioningError.prototype.type = 'ProvisioningError' 44 | ProvisioningError.prototype.name = 'ProvisioningError' 45 | 46 | 47 | /** 48 | * @param {Object} data 49 | * @param {string} msg 50 | * @param {boolean} isWrite 51 | * @param {string} requestId 52 | * @constructor 53 | * @extends {Error} 54 | */ 55 | function ValidationError(data, msg, isWrite, requestId) { 56 | Error.captureStackTrace(this) 57 | this.data = data || {} 58 | this.message = msg 59 | this.table = data.TableName || 'unknown' 60 | this.isWrite = isWrite 61 | this.requestId = requestId 62 | } 63 | util.inherits(ValidationError, Error) 64 | ValidationError.prototype.type = 'ValidationError' 65 | ValidationError.prototype.name = 'ValidationError' 66 | 67 | /** 68 | * @param {string} hashKeyName 69 | * @param {string} rangeKeyName 70 | * @constructor 71 | * @extends {Error} 72 | */ 73 | function IndexNotExistError(hashKeyName, rangeKeyName) { 74 | Error.captureStackTrace(this) 75 | this.hashKeyName = hashKeyName 76 | this.rangeKeyName = rangeKeyName 77 | } 78 | util.inherits(IndexNotExistError, Error) 79 | IndexNotExistError.prototype.type = 'IndexNotExistError' 80 | IndexNotExistError.prototype.name = 'IndexNotExistError' 81 | 82 | /** 83 | * @param {string} returnValues 84 | * @constructor 85 | * @extends {Error} 86 | */ 87 | function InvalidReturnValuesError(returnValues) { 88 | Error.captureStackTrace(this) 89 | this.returnValues = returnValues 90 | } 91 | util.inherits(InvalidReturnValuesError, Error) 92 | InvalidReturnValuesError.prototype.type = 'InvalidReturnValuesError' 93 | InvalidReturnValuesError.prototype.name = 'InvalidReturnValuesError' 94 | 95 | module.exports = { 96 | ConditionalError: ConditionalError, 97 | ProvisioningError: ProvisioningError, 98 | ValidationError: ValidationError, 99 | IndexNotExistError: IndexNotExistError, 100 | InvalidReturnValuesError: InvalidReturnValuesError 101 | } 102 | -------------------------------------------------------------------------------- /lib/localUpdater.js: -------------------------------------------------------------------------------- 1 | // Copyright 2015 A Medium Corporation. 2 | 3 | var typeUtil = require('./typeUtil') 4 | 5 | /** 6 | * @param {Object} oldItem 7 | * @return {Object} 8 | */ 9 | function _cloneObject (oldItem) { 10 | var newItem = {} 11 | for (var key in oldItem) { 12 | if (oldItem.hasOwnProperty(key)) { 13 | newItem[key] = oldItem[key] 14 | } 15 | } 16 | 17 | return newItem 18 | } 19 | 20 | /** 21 | * @param {Object} item 22 | * @param {string} field 23 | * @param {Object} update 24 | */ 25 | function _processDeleteAction (item, field, update) { 26 | // From the dynamo docs: 27 | // 28 | // "If no value is specified, the attribute and its value are removed 29 | // from the item. The data type of the specified value must match the 30 | // existing value's data type. 31 | // 32 | // If a set of values is specified, then those values are subtracted 33 | // from the old set. For example, if the attribute value was the set 34 | // [a,b,c] and the DELETE action specified [a,c], then the final 35 | // attribute value would be [b]. Specifying an empty set is an error." 36 | if (typeUtil.objectIsEmpty(update.Value)) { 37 | // delete a field if it exists 38 | delete item[field] 39 | 40 | } else if (typeUtil.objectIsNonEmptySet(update.Value)) { 41 | // delete the items from the set if they exist 42 | item[field] = typeUtil.deleteFromSet(item[field], update.Value) 43 | if (!item[field]) delete item[field] 44 | 45 | } else { 46 | throw new Error('Trying to DELETE to a specified field from a non-set') 47 | } 48 | } 49 | 50 | /** 51 | * @param {Object} item 52 | * @param {string} field 53 | * @param {Object} update 54 | */ 55 | function _processPutAction (item, field, update) { 56 | // Attribute values cannot be null. String and Binary type attributes must have 57 | // lengths greater than zero. Set type attributes must not be empty. Requests 58 | // with empty values will be rejected with a ValidationException exception. 59 | 60 | if (!typeUtil.objectIsEmpty(update.Value)) { 61 | // set the value of a field 62 | item[field] = update.Value 63 | 64 | } else { 65 | throw new Error('Trying to PUT a field with an empty value') 66 | } 67 | } 68 | 69 | /** 70 | * @param {Object} item 71 | * @param {string} field 72 | * @param {Object} update 73 | */ 74 | function _processAddAction (item, field, update) { 75 | if (typeUtil.objectIsNonEmptySet(update.Value)) { 76 | // append to an array 77 | item[field] = typeUtil.addToSet(item[field], update.Value) 78 | 79 | } else if (typeUtil.objectToType(update.Value) == 'N') { 80 | // increment a number 81 | item[field] = typeUtil.addToNumber(item[field], update.Value) 82 | 83 | } else { 84 | throw new Error('Trying to ADD to a field which isnt an array or number') 85 | } 86 | } 87 | 88 | /** 89 | * @param {Object} oldItem 90 | * @param {Object} updates 91 | * @return {Object} 92 | */ 93 | function update (oldItem, updates) { 94 | if (!oldItem) { 95 | throw new Error('oldItem should not be falsy') 96 | } 97 | var newItem = _cloneObject(oldItem) 98 | 99 | for (var field in updates) { 100 | var update = updates[field] 101 | 102 | // See http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html#DDB-UpdateItem-request-AttributeUpdates 103 | 104 | if (update.Action === 'DELETE') { 105 | _processDeleteAction(newItem, field, update) 106 | } else if (update.Action === 'PUT') { 107 | _processPutAction(newItem, field, update) 108 | } else if (update.Action === 'ADD') { 109 | _processAddAction(newItem, field, update) 110 | } 111 | } 112 | 113 | return newItem 114 | } 115 | 116 | module.exports = { 117 | update: update 118 | } 119 | -------------------------------------------------------------------------------- /lib/reserved.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html 3 | */ 4 | 5 | var list = [ 6 | 'ABORT', 7 | 'ABSOLUTE', 8 | 'ACTION', 9 | 'ADD', 10 | 'AFTER', 11 | 'AGENT', 12 | 'AGGREGATE', 13 | 'ALL', 14 | 'ALLOCATE', 15 | 'ALTER', 16 | 'ANALYZE', 17 | 'AND', 18 | 'ANY', 19 | 'ARCHIVE', 20 | 'ARE', 21 | 'ARRAY', 22 | 'AS', 23 | 'ASC', 24 | 'ASCII', 25 | 'ASENSITIVE', 26 | 'ASSERTION', 27 | 'ASYMMETRIC', 28 | 'AT', 29 | 'ATOMIC', 30 | 'ATTACH', 31 | 'ATTRIBUTE', 32 | 'AUTH', 33 | 'AUTHORIZATION', 34 | 'AUTHORIZE', 35 | 'AUTO', 36 | 'AVG', 37 | 'BACK', 38 | 'BACKUP', 39 | 'BASE', 40 | 'BATCH', 41 | 'BEFORE', 42 | 'BEGIN', 43 | 'BETWEEN', 44 | 'BIGINT', 45 | 'BINARY', 46 | 'BIT', 47 | 'BLOB', 48 | 'BLOCK', 49 | 'BOOLEAN', 50 | 'BOTH', 51 | 'BREADTH', 52 | 'BUCKET', 53 | 'BULK', 54 | 'BY', 55 | 'BYTE', 56 | 'CALL', 57 | 'CALLED', 58 | 'CALLING', 59 | 'CAPACITY', 60 | 'CASCADE', 61 | 'CASCADED', 62 | 'CASE', 63 | 'CAST', 64 | 'CATALOG', 65 | 'CHAR', 66 | 'CHARACTER', 67 | 'CHECK', 68 | 'CLASS', 69 | 'CLOB', 70 | 'CLOSE', 71 | 'CLUSTER', 72 | 'CLUSTERED', 73 | 'CLUSTERING', 74 | 'CLUSTERS', 75 | 'COALESCE', 76 | 'COLLATE', 77 | 'COLLATION', 78 | 'COLLECTION', 79 | 'COLUMN', 80 | 'COLUMNS', 81 | 'COMBINE', 82 | 'COMMENT', 83 | 'COMMIT', 84 | 'COMPACT', 85 | 'COMPILE', 86 | 'COMPRESS', 87 | 'CONDITION', 88 | 'CONFLICT', 89 | 'CONNECT', 90 | 'CONNECTION', 91 | 'CONSISTENCY', 92 | 'CONSISTENT', 93 | 'CONSTRAINT', 94 | 'CONSTRAINTS', 95 | 'CONSTRUCTOR', 96 | 'CONSUMED', 97 | 'CONTINUE', 98 | 'CONVERT', 99 | 'COPY', 100 | 'CORRESPONDING', 101 | 'COUNT', 102 | 'COUNTER', 103 | 'CREATE', 104 | 'CROSS', 105 | 'CUBE', 106 | 'CURRENT', 107 | 'CURSOR', 108 | 'CYCLE', 109 | 'DATA', 110 | 'DATABASE', 111 | 'DATE', 112 | 'DATETIME', 113 | 'DAY', 114 | 'DEALLOCATE', 115 | 'DEC', 116 | 'DECIMAL', 117 | 'DECLARE', 118 | 'DEFAULT', 119 | 'DEFERRABLE', 120 | 'DEFERRED', 121 | 'DEFINE', 122 | 'DEFINED', 123 | 'DEFINITION', 124 | 'DELETE', 125 | 'DELIMITED', 126 | 'DEPTH', 127 | 'DEREF', 128 | 'DESC', 129 | 'DESCRIBE', 130 | 'DESCRIPTOR', 131 | 'DETACH', 132 | 'DETERMINISTIC', 133 | 'DIAGNOSTICS', 134 | 'DIRECTORIES', 135 | 'DISABLE', 136 | 'DISCONNECT', 137 | 'DISTINCT', 138 | 'DISTRIBUTE', 139 | 'DO', 140 | 'DOMAIN', 141 | 'DOUBLE', 142 | 'DROP', 143 | 'DUMP', 144 | 'DURATION', 145 | 'DYNAMIC', 146 | 'EACH', 147 | 'ELEMENT', 148 | 'ELSE', 149 | 'ELSEIF', 150 | 'EMPTY', 151 | 'ENABLE', 152 | 'END', 153 | 'EQUAL', 154 | 'EQUALS', 155 | 'ERROR', 156 | 'ESCAPE', 157 | 'ESCAPED', 158 | 'EVAL', 159 | 'EVALUATE', 160 | 'EXCEEDED', 161 | 'EXCEPT', 162 | 'EXCEPTION', 163 | 'EXCEPTIONS', 164 | 'EXCLUSIVE', 165 | 'EXEC', 166 | 'EXECUTE', 167 | 'EXISTS', 168 | 'EXIT', 169 | 'EXPLAIN', 170 | 'EXPLODE', 171 | 'EXPORT', 172 | 'EXPRESSION', 173 | 'EXTENDED', 174 | 'EXTERNAL', 175 | 'EXTRACT', 176 | 'FAIL', 177 | 'FALSE', 178 | 'FAMILY', 179 | 'FETCH', 180 | 'FIELDS', 181 | 'FILE', 182 | 'FILTER', 183 | 'FILTERING', 184 | 'FINAL', 185 | 'FINISH', 186 | 'FIRST', 187 | 'FIXED', 188 | 'FLATTERN', 189 | 'FLOAT', 190 | 'FOR', 191 | 'FORCE', 192 | 'FOREIGN', 193 | 'FORMAT', 194 | 'FORWARD', 195 | 'FOUND', 196 | 'FREE', 197 | 'FROM', 198 | 'FULL', 199 | 'FUNCTION', 200 | 'FUNCTIONS', 201 | 'GENERAL', 202 | 'GENERATE', 203 | 'GET', 204 | 'GLOB', 205 | 'GLOBAL', 206 | 'GO', 207 | 'GOTO', 208 | 'GRANT', 209 | 'GREATER', 210 | 'GROUP', 211 | 'GROUPING', 212 | 'HANDLER', 213 | 'HASH', 214 | 'HAVE', 215 | 'HAVING', 216 | 'HEAP', 217 | 'HIDDEN', 218 | 'HOLD', 219 | 'HOUR', 220 | 'IDENTIFIED', 221 | 'IDENTITY', 222 | 'IF', 223 | 'IGNORE', 224 | 'IMMEDIATE', 225 | 'IMPORT', 226 | 'IN', 227 | 'INCLUDING', 228 | 'INCLUSIVE', 229 | 'INCREMENT', 230 | 'INCREMENTAL', 231 | 'INDEX', 232 | 'INDEXED', 233 | 'INDEXES', 234 | 'INDICATOR', 235 | 'INFINITE', 236 | 'INITIALLY', 237 | 'INLINE', 238 | 'INNER', 239 | 'INNTER', 240 | 'INOUT', 241 | 'INPUT', 242 | 'INSENSITIVE', 243 | 'INSERT', 244 | 'INSTEAD', 245 | 'INT', 246 | 'INTEGER', 247 | 'INTERSECT', 248 | 'INTERVAL', 249 | 'INTO', 250 | 'INVALIDATE', 251 | 'IS', 252 | 'ISOLATION', 253 | 'ITEM', 254 | 'ITEMS', 255 | 'ITERATE', 256 | 'JOIN', 257 | 'KEY', 258 | 'KEYS', 259 | 'LAG', 260 | 'LANGUAGE', 261 | 'LARGE', 262 | 'LAST', 263 | 'LATERAL', 264 | 'LEAD', 265 | 'LEADING', 266 | 'LEAVE', 267 | 'LEFT', 268 | 'LENGTH', 269 | 'LESS', 270 | 'LEVEL', 271 | 'LIKE', 272 | 'LIMIT', 273 | 'LIMITED', 274 | 'LINES', 275 | 'LIST', 276 | 'LOAD', 277 | 'LOCAL', 278 | 'LOCALTIME', 279 | 'LOCALTIMESTAMP', 280 | 'LOCATION', 281 | 'LOCATOR', 282 | 'LOCK', 283 | 'LOCKS', 284 | 'LOG', 285 | 'LOGED', 286 | 'LONG', 287 | 'LOOP', 288 | 'LOWER', 289 | 'MAP', 290 | 'MATCH', 291 | 'MATERIALIZED', 292 | 'MAX', 293 | 'MAXLEN', 294 | 'MEMBER', 295 | 'MERGE', 296 | 'METHOD', 297 | 'METRICS', 298 | 'MIN', 299 | 'MINUS', 300 | 'MINUTE', 301 | 'MISSING', 302 | 'MOD', 303 | 'MODE', 304 | 'MODIFIES', 305 | 'MODIFY', 306 | 'MODULE', 307 | 'MONTH', 308 | 'MULTI', 309 | 'MULTISET', 310 | 'NAME', 311 | 'NAMES', 312 | 'NATIONAL', 313 | 'NATURAL', 314 | 'NCHAR', 315 | 'NCLOB', 316 | 'NEW', 317 | 'NEXT', 318 | 'NO', 319 | 'NONE', 320 | 'NOT', 321 | 'NULL', 322 | 'NULLIF', 323 | 'NUMBER', 324 | 'NUMERIC', 325 | 'OBJECT', 326 | 'OF', 327 | 'OFFLINE', 328 | 'OFFSET', 329 | 'OLD', 330 | 'ON', 331 | 'ONLINE', 332 | 'ONLY', 333 | 'OPAQUE', 334 | 'OPEN', 335 | 'OPERATOR', 336 | 'OPTION', 337 | 'OR', 338 | 'ORDER', 339 | 'ORDINALITY', 340 | 'OTHER', 341 | 'OTHERS', 342 | 'OUT', 343 | 'OUTER', 344 | 'OUTPUT', 345 | 'OVER', 346 | 'OVERLAPS', 347 | 'OVERRIDE', 348 | 'OWNER', 349 | 'PAD', 350 | 'PARALLEL', 351 | 'PARAMETER', 352 | 'PARAMETERS', 353 | 'PARTIAL', 354 | 'PARTITION', 355 | 'PARTITIONED', 356 | 'PARTITIONS', 357 | 'PATH', 358 | 'PERCENT', 359 | 'PERCENTILE', 360 | 'PERMISSION', 361 | 'PERMISSIONS', 362 | 'PIPE', 363 | 'PIPELINED', 364 | 'PLAN', 365 | 'POOL', 366 | 'POSITION', 367 | 'PRECISION', 368 | 'PREPARE', 369 | 'PRESERVE', 370 | 'PRIMARY', 371 | 'PRIOR', 372 | 'PRIVATE', 373 | 'PRIVILEGES', 374 | 'PROCEDURE', 375 | 'PROCESSED', 376 | 'PROJECT', 377 | 'PROJECTION', 378 | 'PROPERTY', 379 | 'PROVISIONING', 380 | 'PUBLIC', 381 | 'PUT', 382 | 'QUERY', 383 | 'QUIT', 384 | 'QUORUM', 385 | 'RAISE', 386 | 'RANDOM', 387 | 'RANGE', 388 | 'RANK', 389 | 'RAW', 390 | 'READ', 391 | 'READS', 392 | 'REAL', 393 | 'REBUILD', 394 | 'RECORD', 395 | 'RECURSIVE', 396 | 'REDUCE', 397 | 'REF', 398 | 'REFERENCE', 399 | 'REFERENCES', 400 | 'REFERENCING', 401 | 'REGEXP', 402 | 'REGION', 403 | 'REINDEX', 404 | 'RELATIVE', 405 | 'RELEASE', 406 | 'REMAINDER', 407 | 'RENAME', 408 | 'REPEAT', 409 | 'REPLACE', 410 | 'REQUEST', 411 | 'RESET', 412 | 'RESIGNAL', 413 | 'RESOURCE', 414 | 'RESPONSE', 415 | 'RESTORE', 416 | 'RESTRICT', 417 | 'RESULT', 418 | 'RETURN', 419 | 'RETURNING', 420 | 'RETURNS', 421 | 'REVERSE', 422 | 'REVOKE', 423 | 'RIGHT', 424 | 'ROLE', 425 | 'ROLES', 426 | 'ROLLBACK', 427 | 'ROLLUP', 428 | 'ROUTINE', 429 | 'ROW', 430 | 'ROWS', 431 | 'RULE', 432 | 'RULES', 433 | 'SAMPLE', 434 | 'SATISFIES', 435 | 'SAVE', 436 | 'SAVEPOINT', 437 | 'SCAN', 438 | 'SCHEMA', 439 | 'SCOPE', 440 | 'SCROLL', 441 | 'SEARCH', 442 | 'SECOND', 443 | 'SECTION', 444 | 'SEGMENT', 445 | 'SEGMENTS', 446 | 'SELECT', 447 | 'SELF', 448 | 'SEMI', 449 | 'SENSITIVE', 450 | 'SEPARATE', 451 | 'SEQUENCE', 452 | 'SERIALIZABLE', 453 | 'SESSION', 454 | 'SET', 455 | 'SETS', 456 | 'SHARD', 457 | 'SHARE', 458 | 'SHARED', 459 | 'SHORT', 460 | 'SHOW', 461 | 'SIGNAL', 462 | 'SIMILAR', 463 | 'SIZE', 464 | 'SKEWED', 465 | 'SMALLINT', 466 | 'SNAPSHOT', 467 | 'SOME', 468 | 'SOURCE', 469 | 'SPACE', 470 | 'SPACES', 471 | 'SPARSE', 472 | 'SPECIFIC', 473 | 'SPECIFICTYPE', 474 | 'SPLIT', 475 | 'SQL', 476 | 'SQLCODE', 477 | 'SQLERROR', 478 | 'SQLEXCEPTION', 479 | 'SQLSTATE', 480 | 'SQLWARNING', 481 | 'START', 482 | 'STATE', 483 | 'STATIC', 484 | 'STATUS', 485 | 'STORAGE', 486 | 'STORE', 487 | 'STORED', 488 | 'STREAM', 489 | 'STRING', 490 | 'STRUCT', 491 | 'STYLE', 492 | 'SUB', 493 | 'SUBMULTISET', 494 | 'SUBPARTITION', 495 | 'SUBSTRING', 496 | 'SUBTYPE', 497 | 'SUM', 498 | 'SUPER', 499 | 'SYMMETRIC', 500 | 'SYNONYM', 501 | 'SYSTEM', 502 | 'TABLE', 503 | 'TABLESAMPLE', 504 | 'TEMP', 505 | 'TEMPORARY', 506 | 'TERMINATED', 507 | 'TEXT', 508 | 'THAN', 509 | 'THEN', 510 | 'THROUGHPUT', 511 | 'TIME', 512 | 'TIMESTAMP', 513 | 'TIMEZONE', 514 | 'TINYINT', 515 | 'TO', 516 | 'TOKEN', 517 | 'TOTAL', 518 | 'TOUCH', 519 | 'TRAILING', 520 | 'TRANSACTION', 521 | 'TRANSFORM', 522 | 'TRANSLATE', 523 | 'TRANSLATION', 524 | 'TREAT', 525 | 'TRIGGER', 526 | 'TRIM', 527 | 'TRUE', 528 | 'TRUNCATE', 529 | 'TTL', 530 | 'TUPLE', 531 | 'TYPE', 532 | 'UNDER', 533 | 'UNDO', 534 | 'UNION', 535 | 'UNIQUE', 536 | 'UNIT', 537 | 'UNKNOWN', 538 | 'UNLOGGED', 539 | 'UNNEST', 540 | 'UNPROCESSED', 541 | 'UNSIGNED', 542 | 'UNTIL', 543 | 'UPDATE', 544 | 'UPPER', 545 | 'URL', 546 | 'USAGE', 547 | 'USE', 548 | 'USER', 549 | 'USERS', 550 | 'USING', 551 | 'UUID', 552 | 'VACUUM', 553 | 'VALUE', 554 | 'VALUED', 555 | 'VALUES', 556 | 'VARCHAR', 557 | 'VARIABLE', 558 | 'VARIANCE', 559 | 'VARINT', 560 | 'VARYING', 561 | 'VIEW', 562 | 'VIEWS', 563 | 'VIRTUAL', 564 | 'VOID', 565 | 'WAIT', 566 | 'WHEN', 567 | 'WHENEVER', 568 | 'WHERE', 569 | 'WHILE', 570 | 'WINDOW', 571 | 'WITH', 572 | 'WITHIN', 573 | 'WITHOUT', 574 | 'WORK', 575 | 'WRAPPED', 576 | 'WRITE', 577 | 'YEAR', 578 | 'ZONE' 579 | ] 580 | 581 | var set = {} 582 | list.forEach(function (w) { 583 | set[w] = true 584 | }) 585 | 586 | module.exports = { 587 | list: list, 588 | set: set 589 | } 590 | -------------------------------------------------------------------------------- /lib/typeUtil.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation 2 | 3 | /** 4 | * @fileoverview Utility functions that convert plain javascript objects to 5 | * Dynamo AttributeValue map (http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_AttributeValue.html) 6 | * objects back and forth. 7 | */ 8 | 9 | var typ = require('typ') 10 | var reserved = require('./reserved') 11 | 12 | /** 13 | * From http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_AttributeValue.html 14 | * - B: A Binary data type. 15 | * - BOOL: A Boolean data type. 16 | * - BS: A Binary Set data type. 17 | * - L: A List of attribute values. 18 | * - M: A Map of attribute values. 19 | * - N: A Number data type. 20 | * - NS: A Number Set data type. 21 | * - NULL: A Null data type. 22 | * - S: A String data type. 23 | * - SS: A String Set data type. 24 | * 25 | * @typedef {{ 26 | * B: (string|undefined), 27 | * BOOL: (boolean|undefined), 28 | * BS: (Array.|undefined), 29 | * M: (Object|undefined), 30 | * L: (Array|undefined), 31 | * N: (string|undefined), 32 | * NS: (Array.|undefined), 33 | * NULL: (null|undefined), 34 | * S: (string|undefined), 35 | * SS: (Array.|undefined) 36 | * }} 37 | */ 38 | var AWSAttributeValue 39 | 40 | /** 41 | * Convert Dynamo AttributeValue map object(s) to plain javascript object(s) 42 | * 43 | * @param {Object.|Array.>} object Dynamo AttributeValue map object(s) 44 | * @return {Object|Array.} plain javascript object(s) 45 | */ 46 | function unpackObjectOrArray(object) { 47 | if (typ.isNullish(object)) return object 48 | if (Array.isArray(object)) return object.map(unpackObjectOrArray) 49 | 50 | var item = {} 51 | for (var key in object) { 52 | item[key] = objectToValue(object[key]) 53 | } 54 | return item 55 | } 56 | 57 | /** 58 | * Convert an object to a Dynamo AttributeValue map. 59 | * 60 | * @param {Object|Array.|undefined} object 61 | * @param {Object=} attributes an optional map of the attributes that need to convert. 62 | * @return {Object.|Array.>|null} The object in Dynamo AttributeValue map. 63 | */ 64 | function packObjectOrArray(object, attributes) { 65 | if (typ.isNullish(object)) return null 66 | if (Array.isArray(object)) { 67 | return object.map(function (obj) { return packObjectOrArray(obj, attributes) }) 68 | } 69 | 70 | var newObj = {} 71 | for (var key in object) { 72 | if (attributes && !attributes[key]) continue 73 | newObj[key] = valueToObject(object[key]) 74 | } 75 | return newObj 76 | } 77 | 78 | /** 79 | * Convert a javascript primitive value to an AWS AttributeValue 80 | * 81 | * @param {boolean|number|string|Array} value 82 | * @return {AWSAttributeValue|null} 83 | */ 84 | function valueToObject(value) { 85 | var type = typeof value 86 | 87 | switch (typeof value) { 88 | case 'string': 89 | return {S: value} 90 | case 'boolean': 91 | return {BOOL: Boolean(value)} 92 | case 'number': 93 | return {N: String(value)} 94 | default: 95 | if (Array.isArray(value)) { 96 | var firstItemType = typeof value[0] 97 | 98 | // check that all of the items are of the same type; that of the first element's 99 | for (var i = 0; i < value.length; i++) { 100 | if (typeof value[i] !== firstItemType) { 101 | throw new Error('Inconsistent types in set! Expecting all types to be the same as the first element\'s: ' + firstItemType) 102 | } 103 | } 104 | 105 | if (firstItemType === 'string') { 106 | return {SS: value} 107 | } else if (firstItemType === 'number') { 108 | var numArray = [] 109 | for (i = 0; i < value.length; i++) { 110 | numArray.push(String(value[i])) 111 | } 112 | 113 | return {NS: numArray} 114 | } else { 115 | throw new Error('Invalid dynamo set value. Type: ' + firstItemType + ', Value: ' + value[0]) 116 | } 117 | } else { 118 | throw new Error('Invalid dynamo value. Type: ' + type + ', Value: ' + value) 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * Get the type of an AWS AttributeValue 125 | * @param {!AWSAttributeValue} obj Dynamo AttributeValue. 126 | * @return {string} 127 | */ 128 | function objectToType(obj) { 129 | var objectType = Object.keys(obj) 130 | if (objectType.length != 1) { 131 | throw new Error('Expected only one key from Amazon object') 132 | } 133 | 134 | return objectType[0] 135 | } 136 | 137 | /** 138 | * Convert a Dynamo AttributeValue to a javascript primitive value 139 | * 140 | * @param {!AWSAttributeValue} obj 141 | * @return {string|number|Array.|Array.|boolean|Object} a javascript primitive value 142 | */ 143 | function objectToValue(obj) { 144 | switch (objectToType(obj)) { 145 | case 'SS': 146 | return (/** @type {Array.} */(obj.SS)) 147 | case 'S': 148 | return (/** @type {string} */(obj.S)) 149 | case 'BOOL': 150 | return Boolean(obj.BOOL) 151 | case 'NS': 152 | return obj.NS.map(function (num) { return Number(num) }) 153 | case 'N': 154 | return Number(obj.N) 155 | case 'M': 156 | var mapped = {} 157 | for (var k in obj.M) { 158 | mapped[k] = objectToValue(obj.M[k]) 159 | } 160 | return mapped 161 | case 'L': 162 | return obj.L.map(objectToValue) 163 | default: 164 | throw new Error('Unexpected key: ' + objectToType(obj) + ' for attribute: ' + obj) 165 | } 166 | } 167 | 168 | /** 169 | * @param {!AWSAttributeValue} obj 170 | * @return {boolean} 171 | */ 172 | function objectIsEmpty(obj) { 173 | return !obj || Object.keys(obj).length === 0 174 | } 175 | 176 | 177 | /** 178 | * @param {!AWSAttributeValue} obj 179 | * @return {boolean} 180 | */ 181 | function objectIsNonEmptySet(obj) { 182 | if (objectIsEmpty(obj)) return false 183 | 184 | var type = objectToType(obj) 185 | if (type != 'NS' && type != 'SS') return false 186 | 187 | return Array.isArray(obj[type]) && obj[type].length > 0 188 | } 189 | 190 | /** 191 | * @param {!AWSAttributeValue} set 192 | * @param {!AWSAttributeValue} additions 193 | * @return {AWSAttributeValue} 194 | */ 195 | function addToSet(set, additions) { 196 | var type = objectToType(additions) 197 | if (objectIsEmpty(set)) { 198 | set = {} 199 | set[type] = [] 200 | } else if (objectToType(set) === type) { 201 | set = clone(set) 202 | } else { 203 | throw new Error('Type mismatch: type of set should match type of additions') 204 | } 205 | 206 | for (var i = 0; i < additions[type].length; i++) { 207 | if (set[type].indexOf(additions[type][i]) == -1) { 208 | set[type].push(additions[type][i]) 209 | } 210 | } 211 | 212 | return set 213 | } 214 | 215 | /** 216 | * @param {!AWSAttributeValue} set 217 | * @param {!AWSAttributeValue} deletions 218 | * @return {?AWSAttributeValue} 219 | */ 220 | function deleteFromSet(set, deletions) { 221 | var type = objectToType(deletions) 222 | if (objectIsEmpty(set)) { 223 | return null 224 | } else if (objectToType(set) !== type) { 225 | throw new Error('Type mismatch: type of set should match type of deletions') 226 | } 227 | 228 | set = clone(set) 229 | for (var i = 0; i < deletions[type].length; i++) { 230 | var idx = set[type].indexOf(deletions[type][i]) 231 | if (idx != -1) { 232 | set[type].splice(idx, 1) 233 | } 234 | } 235 | 236 | if (set[type].length) { 237 | return set 238 | } else { 239 | return null 240 | } 241 | } 242 | 243 | /** 244 | * @param {!AWSAttributeValue} number 245 | * @param {!AWSAttributeValue} addition 246 | * @return {AWSAttributeValue} 247 | */ 248 | function addToNumber(number, addition) { 249 | if (objectIsEmpty(number)) { 250 | number = {'N': '0'} 251 | } else { 252 | number = clone(number) 253 | } 254 | 255 | if (objectToType(number) !== 'N' || objectToType(addition) !== 'N') { 256 | throw new Error('Type mismatch: number and addition should both be numeric types') 257 | } 258 | 259 | number.N = String(Number(number.N) + Number(addition.N)) 260 | 261 | return number 262 | } 263 | 264 | /** 265 | * @param {!AWSAttributeValue} oldItem 266 | * @return {AWSAttributeValue} 267 | */ 268 | function clone(oldItem) { 269 | try { 270 | var objectType = objectToType(oldItem) 271 | 272 | var newItem = {} 273 | if (Array.isArray(oldItem[objectType])) { 274 | newItem[objectType] = oldItem[objectType].slice() 275 | } else { 276 | newItem[objectType] = oldItem[objectType] 277 | } 278 | return newItem 279 | } catch (e) { 280 | return {NULL:null} 281 | } 282 | } 283 | 284 | var VALID_ATTR_RE = /^[a-zA-Z][a-zA-Z0-9]*$/ 285 | 286 | /** 287 | * @see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ExpressionPlaceholders.html#ExpressionAttributeNames 288 | * @return {string} The alias. May just be the key itself if an alias is not needed. 289 | */ 290 | function getAttributeAlias(key) { 291 | if (isReservedWord(key)) { 292 | // If this is just a reserved word, use # + word 293 | return '#' + key 294 | } 295 | 296 | if (!isAlphaNumeric(key)) { 297 | // if this is not alphanumeric, hex-encode the string. 298 | return '#' + new Buffer(key).toString('hex') 299 | } 300 | 301 | // otherwise, the key is valid in an expression 302 | return key 303 | } 304 | 305 | /** 306 | * @return {boolean} True if this is a reserved word. 307 | */ 308 | function isReservedWord(key) { 309 | return (key.toUpperCase() in reserved.set) 310 | } 311 | 312 | /** 313 | * @return {boolean} True if this matches the alphanumeric regexp 314 | */ 315 | function isAlphaNumeric(key) { 316 | return VALID_ATTR_RE.test(key) 317 | } 318 | 319 | /** 320 | * @see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ExpressionPlaceholders.html#ExpressionAttributeNames 321 | * @return {boolean} True if we need an attribute alias. 322 | */ 323 | function needsAttributeAlias(key) { 324 | return isReservedWord(key) || !isAlphaNumeric(key) 325 | } 326 | 327 | /** 328 | * Given a list of attribute names, return an object suitable for ExpressionAttributeNames 329 | * @param {Array} attrList 330 | * @return {Object} 331 | */ 332 | function buildAttributeNames(attrList) { 333 | var result = {} 334 | attrList.map(function (key) { 335 | if (needsAttributeAlias(key)) { 336 | result[getAttributeAlias(key)] = key 337 | } 338 | }, this) 339 | return result 340 | } 341 | 342 | /** 343 | * Extends the attribute names object with new names. 344 | */ 345 | function extendAttributeNames(existingNames, newNames) { 346 | for (var key in newNames) { 347 | if (!existingNames[key]) { 348 | existingNames[key] = newNames[key] 349 | } else if (existingNames[key] != newNames[key]) { 350 | throw new Error('Attribute name conflict ' + key) 351 | } 352 | } 353 | } 354 | 355 | /** 356 | * Extends the attribute values object with new values. 357 | */ 358 | function extendAttributeValues(existingValues, newValues) { 359 | for (var key in newValues) { 360 | if (!existingValues[key]) { 361 | existingValues[key] = newValues[key] 362 | } else{ 363 | throw new Error('Attribute value conflict ' + key) 364 | } 365 | } 366 | } 367 | 368 | module.exports = { 369 | AWSAttributeValue: AWSAttributeValue, 370 | 371 | unpackObjectOrArray: unpackObjectOrArray, 372 | packObjectOrArray: packObjectOrArray, 373 | valueToObject: valueToObject, 374 | objectToType: objectToType, 375 | objectToValue: objectToValue, 376 | objectIsEmpty: objectIsEmpty, 377 | objectIsNonEmptySet: objectIsNonEmptySet, 378 | 379 | getAttributeAlias: getAttributeAlias, 380 | buildAttributeNames: buildAttributeNames, 381 | extendAttributeNames: extendAttributeNames, 382 | extendAttributeValues: extendAttributeValues, 383 | 384 | addToSet: addToSet, 385 | deleteFromSet: deleteFromSet, 386 | addToNumber: addToNumber, 387 | clone: clone 388 | } 389 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamite", 3 | "description": "promise-based DynamoDB client", 4 | "version": "1.0.0", 5 | "homepage": "https://github.com/Medium/dynamite", 6 | "license": "Apache-2.0", 7 | "authors": [ 8 | "Jeremy Stanley (https://github.com/azulus)", 9 | "Jean Hsu (https://github.com/jyhsu)", 10 | "Jonathan Fuchs (https://github.com/jfuchs)", 11 | "Artem Titoulenko (https://github.com/ArtemTitoulenko)", 12 | "Xiao Ma (https://github.com/x-ma)", 13 | "Jamie Talbot (https://github.com/majelbstoat)" 14 | ], 15 | "keywords": [ 16 | "dynamite", 17 | "dynamo", 18 | "dynamodb" 19 | ], 20 | "main": "dynamite.js", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/Medium/dynamite.git" 24 | }, 25 | "dependencies": { 26 | "aws-sdk": "^2.368.0", 27 | "kew": "git+https://github.com/Medium/kew#b8aaf9f", 28 | "typ": "0.6.3" 29 | }, 30 | "devDependencies": { 31 | "eslint": "4.18.2", 32 | "local-dynamo": "git+https://github.com/Medium/local-dynamo#4d8d3c0", 33 | "nodeunit": "0.11.3", 34 | "nodeunitq": "0.1.1" 35 | }, 36 | "externDependencies": { 37 | "aws-sdk": "./externs/aws-sdk.js" 38 | }, 39 | "scripts": { 40 | "test": "eslint . && ./node_modules/.bin/nodeunit test" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/testBatchGetItem.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var nodeunitq = require('nodeunitq') 5 | var builder = new nodeunitq.Builder(exports) 6 | var Q = require('kew') 7 | 8 | function onError(err) { 9 | console.error(err.stack) 10 | } 11 | 12 | var userData = [ 13 | {'userId': 'userA', 'column': '@', 'age': '29'}, 14 | {'userId': 'userB', 'column': '@', 'age': '44'}, 15 | {'userId': 'userC', 'column': '@', 'age': '36'} 16 | ] 17 | 18 | var phoneData = [ 19 | {'userId': 'userA', 'column': 'phone1', 'number': '415-662-1234'}, 20 | {'userId': 'userA', 'column': 'phone2', 'number': '650-143-8899'}, 21 | {'userId': 'userB', 'column': 'phone1', 'number': '550-555-5555'} 22 | ] 23 | 24 | // Generate many items that will exceed the batch get count limit 25 | var manyData = [] 26 | for (var i = 0; i < 202; i++) { 27 | manyData.push({'hashKey': 'id' + i, 'column': '@', 'data': 'small'}) 28 | } 29 | 30 | // Generate big items that will exceed amount allowed to be returned. 31 | var muchoData = [] 32 | var junk = new Array(62000).join('.') 33 | for (i = 0; i < 101; i++) { 34 | muchoData.push({'hashKey': 'id' + i, 'column': '@', 'data': junk}) 35 | } 36 | 37 | // basic setup for the tests, creating record userA with range key @ 38 | exports.setUp = function (done) { 39 | this.db = utils.getMockDatabase() 40 | this.client = utils.getMockDatabaseClient() 41 | utils.ensureLocalDynamo() 42 | 43 | var userTablePromise = utils.createTable(this.db, 'user', 'userId', 'column') 44 | .thenBound(utils.initTable, null, {db: this.db, tableName: 'user', data: userData}) 45 | 46 | var phoneTablePromise = utils.createTable(this.db, 'phones', 'userId', 'column') 47 | .thenBound(utils.initTable, null, {db: this.db, tableName: 'phones', data: phoneData}) 48 | 49 | var manyTablePromise = utils.createTable(this.db, 'pre_many', 'hashKey', 'column') 50 | .thenBound(utils.initTable, null, {db: this.db, tableName: 'pre_many', data: manyData}) 51 | 52 | var muchoTablePromise = utils.createTable(this.db, 'mucho', 'hashKey', 'column') 53 | .thenBound(utils.initTable, null, {db: this.db, tableName: 'mucho', data: muchoData}) 54 | 55 | Q.all([userTablePromise, phoneTablePromise, manyTablePromise, muchoTablePromise]) 56 | .fail(onError) 57 | .fin(done) 58 | } 59 | 60 | exports.tearDown = function (done) { 61 | Q.all([ 62 | utils.deleteTable(this.db, 'user'), 63 | utils.deleteTable(this.db, 'phones'), 64 | utils.deleteTable(this.db, 'pre_many'), 65 | utils.deleteTable(this.db, 'mucho') 66 | ]) 67 | .fin(done) 68 | } 69 | 70 | builder.add(function testBatchGet(test) { 71 | return this.client.newBatchGetBuilder() 72 | .requestItems('user', [{'userId': 'userA', 'column': '@'}, {'userId': 'userB', 'column': '@'}]) 73 | .requestItems('phones', [{'userId': 'userA', 'column': 'phone1'}, {'userId': 'userB', 'column': 'phone1'}]) 74 | .execute() 75 | .then(function (data) { 76 | var ages = data.result.user.map(function (user) { return user.age }) 77 | test.deepEqual(ages, ['29', '44']) 78 | var phones = data.result.phones.map(function (phone) { return phone.number }) 79 | test.deepEqual(phones, ['415-662-1234', '550-555-5555']) 80 | }) 81 | }) 82 | 83 | builder.add(function testEmptyBatch(test) { 84 | return this.client.newBatchGetBuilder() 85 | .requestItems('user', [{'userId': 'userE', 'column': '@'}]) 86 | .execute() 87 | .then(function (data) { 88 | test.ok(Array.isArray(data.result.user), 'An array should be returned for requested tables') 89 | test.equal(0, data.result.user.length, 'No items should have been returned') 90 | }) 91 | }) 92 | 93 | builder.add(function testBatchGetMany(test) { 94 | return this.client.newBatchGetBuilder() 95 | .setPrefix('pre_') 96 | .requestItems('many', manyData.map(function (o) { return {'hashKey': o.hashKey, 'column': '@'}})) 97 | .execute() 98 | .then(function (data) { 99 | test.equal(202, data.result.many.length, 'All 202 items should be returned') 100 | test.equal(0, Object.keys(data.UnprocessedKeys).length, 'There should be no unprocessed keys') 101 | }) 102 | }) 103 | 104 | builder.add(function testBatchGetMucho(test) { 105 | return this.client.newBatchGetBuilder() 106 | .requestItems('mucho', muchoData.map(function (o) { return {'hashKey': o.hashKey, 'column': '@'}})) 107 | .execute() 108 | .then(function (data) { 109 | test.equal(101, data.result.mucho.length, 'All 101 items should be returned') 110 | test.equal(0, Object.keys(data.UnprocessedKeys), 'There should be no unprocessed keys') 111 | }) 112 | }) 113 | 114 | builder.add(function testBatchGetBadKey(test) { 115 | var client = this.client 116 | return Q.fcall( 117 | function () { 118 | return client.newBatchGetBuilder() 119 | .setPrefix('pre_') 120 | .requestItems('many', manyData.map(function () { return {'hashKey': {}, 'column': '@'}})) 121 | .execute() 122 | }) 123 | .then(function () { 124 | test.fail('Expected error') 125 | }) 126 | .fail(function (err) { 127 | if (err.message != 'Invalid dynamo value. Type: object, Value: [object Object]') throw err 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /test/testConditions.js: -------------------------------------------------------------------------------- 1 | // Copyright 2016 A Medium Corporation 2 | 'use strict' 3 | 4 | var utils = require('./utils/testUtils.js') 5 | var nodeunitq = require('nodeunitq') 6 | var builder = new nodeunitq.Builder(exports) 7 | 8 | var onError = console.error.bind(console) 9 | var tableName = 'user' 10 | var rawData = [ 11 | {userId: 'a', column: '@'}, 12 | {userId: 'b', column: '@', blacklistedAt: '1'}, 13 | {userId: 'c', column: '@', blacklistedAt: '1', unblacklistedAt: '2'}, 14 | {userId: 'd', column: '@', blacklistedAt: '3', unblacklistedAt: '2'}, 15 | {userId: 'e', column: '@', blacklistedAt: '4', unblacklistedAt: '4'} 16 | ] 17 | 18 | var db 19 | var client 20 | 21 | exports.setUp = function (done) { 22 | db = utils.getMockDatabase() 23 | client = utils.getMockDatabaseClient() 24 | utils.ensureLocalDynamo() 25 | utils.createTable(db, tableName, 'userId', 'column') 26 | .thenBound(utils.initTable, null, {"db": db, "tableName": tableName, "data": rawData}) 27 | .fail(onError) 28 | .fin(done) 29 | } 30 | 31 | exports.tearDown = function (done) { 32 | utils.deleteTable(db, tableName) 33 | .fin(done) 34 | } 35 | 36 | builder.add(function testEqualsAttribute(test) { 37 | var filter = client.newConditionBuilder() 38 | .filterAttributeEqualsAttribute('blacklistedAt', 'unblacklistedAt') 39 | return assertUserIds(test, ['e'], filter) 40 | }) 41 | 42 | builder.add(function testNotEqualsAttribute(test) { 43 | var filter = client.newConditionBuilder() 44 | .filterAttributeNotEqualsAttribute('blacklistedAt', 'unblacklistedAt') 45 | return assertUserIds(test, ['a', 'b', 'c', 'd'], filter) 46 | }) 47 | 48 | builder.add(function testLessThanAttribute(test) { 49 | var filter = client.newConditionBuilder() 50 | .filterAttributeLessThanAttribute('blacklistedAt', 'unblacklistedAt') 51 | return assertUserIds(test, ['c'], filter) 52 | }) 53 | 54 | builder.add(function testLessThanEqualAttribute(test) { 55 | var filter = client.newConditionBuilder() 56 | .filterAttributeLessThanEqualAttribute('blacklistedAt', 'unblacklistedAt') 57 | return assertUserIds(test, ['c', 'e'], filter) 58 | }) 59 | 60 | builder.add(function testGreaterThanAttribute(test) { 61 | var filter = client.newConditionBuilder() 62 | .filterAttributeGreaterThanAttribute('blacklistedAt', 'unblacklistedAt') 63 | return assertUserIds(test, ['d'], filter) 64 | }) 65 | 66 | builder.add(function testGreaterThanEqualAttribute(test) { 67 | var filter = client.newConditionBuilder() 68 | .filterAttributeGreaterThanEqualAttribute('blacklistedAt', 'unblacklistedAt') 69 | return assertUserIds(test, ['d', 'e'], filter) 70 | }) 71 | 72 | function assertUserIds(test, expectedUserIds, filter) { 73 | return client.newScanBuilder(tableName) 74 | .withFilter(filter) 75 | .setLimit(10) 76 | .execute() 77 | .then(function (data) { 78 | var userIds = data.result.map(function (r) { 79 | return r.userId 80 | }) 81 | test.deepEqual(expectedUserIds, userIds.sort()) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /test/testDeleteItem.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var typ = require('typ') 5 | var nodeunitq = require('nodeunitq') 6 | var builder = new nodeunitq.Builder(exports) 7 | 8 | var onError = console.error.bind(console) 9 | var initialData = [{"userId": "userA", "column": "@", "age": "29"}] 10 | 11 | // basic setup for the tests, creating record userA with range key @ 12 | exports.setUp = function (done) { 13 | this.db = utils.getMockDatabase() 14 | this.client = utils.getMockDatabaseClient() 15 | utils.ensureLocalDynamo() 16 | utils.createTable(this.db, "user", "userId", "column") 17 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 18 | .fail(onError) 19 | .fin(done) 20 | } 21 | 22 | exports.tearDown = function (done) { 23 | utils.deleteTable(this.db, "user") 24 | .fin(done) 25 | } 26 | 27 | // check that an item can be deleted 28 | builder.add(function testDeleteExistingItem(test) { 29 | var self = this 30 | 31 | return this.client.deleteItem('user') 32 | .setHashKey('userId', 'userA') 33 | .setRangeKey('column', '@') 34 | .execute() 35 | .then(function () { 36 | return utils.getItemWithSDK(self.db, "userA", "@") 37 | }) 38 | .then(function (data) { 39 | test.equal(data['Item'], undefined, "User should be deleted~~~" + JSON.stringify(data)) 40 | }) 41 | }) 42 | 43 | // check that an item isn't inadvertently deleted when deleting another 44 | builder.add(function testDeleteNonexistingItem(test) { 45 | var self = this 46 | 47 | return this.client.deleteItem('user') 48 | .setHashKey('userId', 'userB') 49 | .setRangeKey('column', '@') 50 | .execute() 51 | .then(function () { 52 | return utils.getItemWithSDK(self.db, "userA", "@") 53 | }) 54 | .then(function (data) { 55 | test.equal(typ.isNullish(data['Item']), false, "User should not be deleted") 56 | }) 57 | }) 58 | 59 | // check that an item matches a conditional when deleting 60 | builder.add(function testDeleteExistingItemWithConditional(test) { 61 | var self = this 62 | 63 | var conditions = this.client.newConditionBuilder() 64 | .expectAttributeEquals('column', '@') 65 | 66 | return this.client.deleteItem('user') 67 | .setHashKey('userId', 'userA') 68 | .setRangeKey('column', '@') 69 | .withCondition(conditions) 70 | .execute() 71 | .then(function () { 72 | return utils.getItemWithSDK(self.db, "userA", "@") 73 | }) 74 | .then(function (data) { 75 | test.equal(data['Item'], undefined, "User should be deleted") 76 | }) 77 | }) 78 | 79 | // check that an item matches an absent conditional when deleting 80 | builder.add(function testDeleteExistingItemWithConditionalAbsent(test) { 81 | var self = this 82 | 83 | var conditions = this.client.newConditionBuilder() 84 | .expectAttributeAbsent('height') 85 | 86 | return this.client.deleteItem('user') 87 | .setHashKey('userId', 'userA') 88 | .setRangeKey('column', '@') 89 | .withCondition(conditions) 90 | .execute() 91 | .then(function () { 92 | return utils.getItemWithSDK(self.db, "userA", "@") 93 | }) 94 | .then(function (data) { 95 | test.equal(data['Item'], undefined, "User should be deleted") 96 | }) 97 | }) 98 | 99 | // check that an item fails a conditional when deleting 100 | builder.add(function testDeleteExistingItemWithFailedConditional(test) { 101 | var conditions = this.client.newConditionBuilder() 102 | .expectAttributeEquals('column', 'bug') 103 | 104 | return this.client.deleteItem('user') 105 | .setHashKey('userId', 'userA') 106 | .setRangeKey('column', '@') 107 | .withCondition(conditions) 108 | .execute() 109 | .then(function () { 110 | test.fail("'testDeleteExistingItemWithFailedConditional' failed") 111 | }) 112 | .fail(this.client.throwUnlessConditionalError) 113 | }) 114 | 115 | // check that an item fails an absent conditional when deleting 116 | builder.add(function testDeleteExistingItemWithFailedAbsentConditional(test) { 117 | var conditions = this.client.newConditionBuilder() 118 | .expectAttributeAbsent('age') 119 | 120 | return this.client.deleteItem('user') 121 | .setHashKey('userId', 'userA') 122 | .setRangeKey('column', '@') 123 | .withCondition(conditions) 124 | .execute() 125 | .then(function () { 126 | test.fail("'testDeleteExistingItemWithFailedAbsentConditional' failed") 127 | }) 128 | .fail(this.client.throwUnlessConditionalError) 129 | }) 130 | 131 | // check that non-existent items can't be deleted if a conditional expects a value 132 | builder.add(function testDeleteNonexistingItemWithConditional(test) { 133 | var conditions = this.client.newConditionBuilder() 134 | .expectAttributeEquals('column', '@') 135 | 136 | return this.client.deleteItem('user') 137 | .setHashKey('userId', 'userB') 138 | .setRangeKey('column', '@') 139 | .withCondition(conditions) 140 | .execute() 141 | .then(function () { 142 | test.fail("'testDeleteNonexistingItemWithConditional' failed") 143 | }) 144 | .fail(this.client.throwUnlessConditionalError) 145 | }) 146 | 147 | // check that non-existent items can't be deleted if a conditional expects a value 148 | builder.add(function testDeleteNonexistingItemWithConditionalAbsent(test) { 149 | var self = this 150 | 151 | var conditions = this.client.newConditionBuilder() 152 | .expectAttributeAbsent('column') 153 | 154 | return this.client.deleteItem('user') 155 | .setHashKey('userId', 'userB') 156 | .setRangeKey('column', '@') 157 | .withCondition(conditions) 158 | .execute() 159 | .then(function () { 160 | return utils.getItemWithSDK(self.db, "userA", "@") 161 | }) 162 | .then(function (data) { 163 | test.equal(typ.isNullish(data['Item']), false, "User should not be deleted") 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /test/testDescribeTable.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var nodeunitq = require('nodeunitq') 5 | var builder = new nodeunitq.Builder(exports) 6 | 7 | var onError = console.error.bind(console) 8 | 9 | // basic setup for the tests, creating record userA with range key @ 10 | exports.setUp = function (done) { 11 | this.db = utils.getMockDatabase() 12 | this.client = utils.getMockDatabaseClient() 13 | utils.ensureLocalDynamo() 14 | utils.createTable(this.db, "user", "userId", "column") 15 | .fail(onError) 16 | .fin(done) 17 | } 18 | 19 | exports.tearDown = function (done) { 20 | utils.deleteTable(this.db, "user") 21 | .fin(done) 22 | } 23 | 24 | // check that an item exists 25 | builder.add(function testSimpleDescribeTable(test) { 26 | return this.client.describeTable("user") 27 | .execute() 28 | .then(function (data) { 29 | test.equal(data.Table.TableName, "user", "Table name should be 'user'") 30 | test.equal(data.Table.KeySchema[0].AttributeName, "userId", "Hash key name should be 'userId'") 31 | test.equal(data.Table.KeySchema[1].AttributeName, "column", "Hash key name should be 'column'") 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/testGetItem.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var nodeunitq = require('nodeunitq') 5 | var builder = new nodeunitq.Builder(exports) 6 | 7 | var onError = console.error.bind(console) 8 | var initialData = [{"userId": "userA", "column": "@", "age": "29"}] 9 | 10 | // basic setup for the tests, creating record userA with range key @ 11 | exports.setUp = function (done) { 12 | this.db = utils.getMockDatabase() 13 | this.client = utils.getMockDatabaseClient() 14 | utils.ensureLocalDynamo() 15 | utils.createTable(this.db, "user", "userId", "column") 16 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 17 | .fail(onError) 18 | .fin(done) 19 | } 20 | 21 | exports.tearDown = function (done) { 22 | utils.deleteTable(this.db, "user") 23 | .fin(done) 24 | } 25 | 26 | // check that an item exists 27 | builder.add(function testItemExists(test) { 28 | return this.client.getItem('user') 29 | .setHashKey('userId', 'userA') 30 | .setRangeKey('column', '@') 31 | .execute() 32 | .then(function (data) { 33 | test.equal(data.result.age, 29, 'Age should match the provided age') 34 | }) 35 | }) 36 | 37 | // check that only selected attributes are returned 38 | builder.add(function testSelectedAttributes(test) { 39 | return this.client.getItem('user') 40 | .setHashKey('userId', 'userA') 41 | .setRangeKey('column', '@') 42 | .selectAttributes(['userId', 'column']) 43 | .execute() 44 | .then(function (data) { 45 | test.equal(data.result.column, '@', 'Column should be defined') 46 | test.equal(data.result.age, undefined, 'Age should be undefined') 47 | }) 48 | }) 49 | 50 | // check that an item doesn't exist 51 | builder.add(function testItemDoesNotExist(test) { 52 | return this.client.getItem('user') 53 | .setHashKey('userId', 'userB') 54 | .setRangeKey('column', '@') 55 | .execute() 56 | .then(function (data) { 57 | test.equal(data.result, undefined, 'Record should not exist') 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/testGetSet.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils/testUtils.js') 2 | var nodeunitq = require('nodeunitq') 3 | var builder = new nodeunitq.Builder(exports) 4 | 5 | var onError = console.error.bind(console) 6 | var initialData = [ {"userId": "userA", "column": "@", "postIds": ['1a', '1b', '1c']} 7 | , {"userId": "userB", "column": "@", "postIds": [1, 2, 3]} 8 | , {"userId": "userC", "column": "@"}] 9 | 10 | /* 11 | * Sets up for test, and creates a record userA with range key @. 12 | */ 13 | exports.setUp = function (done) { 14 | this.db = utils.getMockDatabase() 15 | this.client = utils.getMockDatabaseClient() 16 | utils.ensureLocalDynamo() 17 | utils.createTable(this.db, "user", "userId", "column") 18 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 19 | .fail(onError) 20 | .fin(done) 21 | } 22 | 23 | exports.tearDown = function (done) { 24 | utils.deleteTable(this.db, "user") 25 | .fin(done) 26 | } 27 | 28 | // get the set of strings 29 | builder.add(function testStringSetRetrieve(test) { 30 | return this.client.getItem('user') 31 | .setHashKey('userId', 'userA') 32 | .setRangeKey('column', '@') 33 | .execute() 34 | .then(function (data) { 35 | test.deepEqual(data.result.postIds, ['1a', '1b', '1c'], "postIds should be ['1a', '1b', '1c']") 36 | }) 37 | }) 38 | 39 | // get the set of numbers 40 | builder.add(function testNumberSetRetrieve(test) { 41 | return this.client.getItem('user') 42 | .setHashKey('userId', 'userB') 43 | .setRangeKey('column', '@') 44 | .execute() 45 | .then(function (data) { 46 | test.deepEqual(data.result.postIds, [1, 2, 3], "postIds should be [1, 2, 3]") 47 | }) 48 | }) 49 | 50 | // get a set that doesn't exist 51 | builder.add(function testSetDoesNotExist(test) { 52 | return this.client.getItem('user') 53 | .setHashKey('userId', 'userC') 54 | .setRangeKey('column', '@') 55 | .execute() 56 | .then(function (data) { 57 | test.equal(data.result.postIds, undefined, "postIds should not exist for userC") 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/testHashKeyOnly.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | var utils = require('./utils/testUtils.js') 3 | var nodeunitq = require('nodeunitq') 4 | var builder = new nodeunitq.Builder(exports) 5 | 6 | var onError = function (err) { 7 | console.error(err.stack) 8 | } 9 | var initialData = [{"userId": "userA", "column": "@", "age": "29"}] 10 | 11 | // basic setup for the tests, creating record userA with range key @ 12 | exports.setUp = function (done) { 13 | var self = this 14 | this.db = utils.getMockDatabase() 15 | this.client = utils.getMockDatabaseClient() 16 | utils.ensureLocalDynamo() 17 | utils.createTable(self.db, "userRangeOnly", "userId") 18 | .thenBound(utils.initTable, null, {db: self.db, tableName: "userRangeOnly", data: initialData}) 19 | .fail(onError) 20 | .fin(done) 21 | } 22 | 23 | exports.tearDown = function (done) { 24 | utils.deleteTable(this.db, 'userRangeOnly') 25 | .fin(done) 26 | } 27 | 28 | // check that an item exists 29 | builder.add(function testItemExists(test) { 30 | return this.client.getItem('userRangeOnly') 31 | .setHashKey('userId', 'userA') 32 | .execute() 33 | .then(function (data) { 34 | test.equal(data.result.age, 29, 'Age should match the provided age') 35 | }) 36 | }) 37 | 38 | // put an item and check that it exists 39 | builder.add(function testSimplePut(test) { 40 | var self = this 41 | return this.client.putItem("userRangeOnly", { 42 | userId: 'userB', 43 | column: '@', 44 | age: 30 45 | }) 46 | .execute() 47 | .then(function () { 48 | return utils.getItemWithSDK(self.db, "userB", null, 'userRangeOnly') 49 | }) 50 | .then(function (data) { 51 | test.equal(data['Item']['age'].N, "30", "Age should be set") 52 | }) 53 | }) 54 | 55 | // put a list of strings and check if they exist 56 | builder.add(function testStringSetPut(test) { 57 | var self = this 58 | return this.client.putItem("userRangeOnly", { 59 | userId: 'userC', 60 | column: '@', 61 | postIds: ['3a', '3b', '3c'] 62 | }) 63 | .execute() 64 | .then(function () { 65 | return utils.getItemWithSDK(self.db, "userC", null, 'userRangeOnly') 66 | }) 67 | .then(function (data) { 68 | test.deepEqual(data['Item']['postIds'].SS, ['3a', '3b', '3c'], "postIds should be ['3a', '3b', '3c']") 69 | }) 70 | }) 71 | 72 | // test putting an attribute for an existing record 73 | builder.add(function testPutAttributeExisting(test) { 74 | var self = this 75 | 76 | return this.client.newUpdateBuilder('userRangeOnly') 77 | .setHashKey('userId', 'userA') 78 | .enableUpsert() 79 | .putAttribute('age', 30) 80 | .putAttribute('height', 72) 81 | .execute() 82 | .then(function (data) { 83 | test.equal(data.result.age, 30, 'result age should be 30') 84 | test.equal(data.result.height, 72, 'height should be 72') 85 | return utils.getItemWithSDK(self.db, "userA", null, 'userRangeOnly') 86 | }) 87 | .then(function (data) { 88 | test.equal(data['Item']['age'].N, "30", "result age should be 30") 89 | test.equal(data['Item']['height'].N, "72", "height should be 72") 90 | }) 91 | }) 92 | 93 | 94 | builder.add(function testDeleteItem(test) { 95 | //AWS.config.logger = process.stdout 96 | 97 | var self = this 98 | return self.client.deleteItem('userRangeOnly') 99 | .setHashKey('userId', 'userA') 100 | .execute() 101 | .then(function () { 102 | return utils.getItemWithSDK(self.db, "userA", null, "userRangeOnly") 103 | }) 104 | .then(function (data) { 105 | test.equal(data['Item'], undefined, "User should be deleted~~~" + JSON.stringify(data)) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/testLocalUpdater.js: -------------------------------------------------------------------------------- 1 | // Copyright 2015 A Medium Corporation. 2 | 3 | var typeUtil = require('../lib/typeUtil') 4 | var localUpdater = require('../lib/localUpdater') 5 | var nodeunitq = require('nodeunitq') 6 | var builder = new nodeunitq.Builder(exports) 7 | var Q = require('kew') 8 | 9 | builder.add(function testDelete(test) { 10 | var data = typeUtil.packObjectOrArray({ 11 | "userId": "userA", 12 | "column": "@", 13 | "age": 29, 14 | "someStringSet": ['a', 'b', 'c'] 15 | }) 16 | 17 | var updated = localUpdater.update(data, { 18 | 'age': { 19 | Action: 'DELETE' 20 | } 21 | }) 22 | 23 | test.equal(data.age.N, 29, 'age should not change') 24 | test.ok(updated.age === undefined, 'age should be undefined') 25 | return Q.resolve() 26 | }) 27 | 28 | builder.add(function testDeleteFromSet(test) { 29 | var data = typeUtil.packObjectOrArray({ 30 | "userId": "userA", 31 | "column": "@", 32 | "age": 29, 33 | "someStringSet": ['a', 'b', 'c'] 34 | }) 35 | 36 | var updated = localUpdater.update(data, { 37 | 'someStringSet': { 38 | Action: 'DELETE', 39 | Value: typeUtil.valueToObject(['b','c','d']) 40 | } 41 | }) 42 | 43 | test.deepEqual(data.someStringSet.SS, ['a', 'b', 'c'], 'someStringSet should not change') 44 | test.deepEqual(updated.someStringSet.SS, ['a'], 'someStringSet should equal [\'a\']') 45 | return Q.resolve() 46 | }) 47 | 48 | builder.add(function testAddToSet(test) { 49 | var data = typeUtil.packObjectOrArray({ 50 | "userId": "userA", 51 | "column": "@", 52 | "age": 29, 53 | "someStringSet": ['a', 'b', 'c'] 54 | }) 55 | 56 | var updated = localUpdater.update(data, { 57 | 'someStringSet': { 58 | Action: 'ADD', 59 | Value: typeUtil.valueToObject(['b','c','d']) 60 | } 61 | }) 62 | 63 | test.deepEqual(data.someStringSet.SS, ['a', 'b', 'c'], 'someStringSet should not change') 64 | test.deepEqual(updated.someStringSet.SS, ['a', 'b', 'c', 'd'], 'someStringSet should equal [\'a\', \'b\', \'c\', \'d\']') 65 | return Q.resolve() 66 | }) 67 | 68 | builder.add(function testAddToEmptySet(test) { 69 | var data = typeUtil.packObjectOrArray({ 70 | "userId": "userA", 71 | "column": "@", 72 | "age": 29 73 | }) 74 | 75 | var updated = localUpdater.update(data, { 76 | 'someStringSet': { 77 | Action: 'ADD', 78 | Value: typeUtil.valueToObject(['b','c','d']) 79 | } 80 | }) 81 | 82 | test.ok(data.someStringSet === undefined, 'someStringSet should not change') 83 | test.deepEqual(updated.someStringSet.SS, ['b', 'c', 'd'], 'someStringSet should equal [\'a\', \'b\', \'c\', \'d\']') 84 | return Q.resolve() 85 | }) 86 | 87 | builder.add(function testAddToNumber(test) { 88 | var data = typeUtil.packObjectOrArray({ 89 | "userId": "userA", 90 | "column": "@", 91 | "age": 29 92 | }) 93 | 94 | var updated = localUpdater.update(data, { 95 | 'age': { 96 | Action: 'ADD', 97 | Value: typeUtil.valueToObject(1) 98 | } 99 | }) 100 | 101 | test.deepEqual(data.age.N, 29, 'age should not change') 102 | test.deepEqual(updated.age.N, '30', 'age should equal 30') 103 | return Q.resolve() 104 | }) 105 | 106 | builder.add(function testAddToEmptyNumber(test) { 107 | var data = typeUtil.packObjectOrArray({ 108 | "userId": "userA", 109 | "column": "@" 110 | }) 111 | 112 | var updated = localUpdater.update(data, { 113 | 'age': { 114 | Action: 'ADD', 115 | Value: typeUtil.valueToObject(30) 116 | } 117 | }) 118 | 119 | test.ok(data.age === undefined, 'age should not change') 120 | test.deepEqual(updated.age.N, '30', 'age should equal 30') 121 | return Q.resolve() 122 | }) 123 | 124 | builder.add(function testPut(test) { 125 | var data = typeUtil.packObjectOrArray({ 126 | "userId": "userA", 127 | "column": "@", 128 | "age": 29, 129 | "someStringSet": ['a', 'b', 'c'] 130 | }) 131 | 132 | var updated = localUpdater.update(data, { 133 | 'someStringSet': { 134 | Action: 'PUT', 135 | Value: typeUtil.valueToObject(['b','c','d']) 136 | } 137 | }) 138 | 139 | test.deepEqual(data.someStringSet.SS, ['a', 'b', 'c'], 'someStringSet should not change') 140 | test.deepEqual(updated.someStringSet.SS, ['b', 'c', 'd'], 'someStringSet should equal [\'b\', \'c\', \'d\']') 141 | return Q.resolve() 142 | }) 143 | -------------------------------------------------------------------------------- /test/testPutItem.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var nodeunitq = require('nodeunitq') 5 | var builder = new nodeunitq.Builder(exports) 6 | var errors = require('../lib/errors') 7 | 8 | var onError = console.error.bind(console) 9 | var initialData = [{"userId": "userA", "column": "@", "age": "29"}] 10 | 11 | /* 12 | * Sets up for test, and creates a record userA with range key @. 13 | */ 14 | exports.setUp = function (done) { 15 | this.db = utils.getMockDatabase() 16 | this.client = utils.getMockDatabaseClient() 17 | utils.ensureLocalDynamo() 18 | utils.createTable(this.db, "user", "userId", "column") 19 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 20 | .fail(onError) 21 | .fin(done) 22 | } 23 | 24 | exports.tearDown = function (done) { 25 | utils.deleteTable(this.db, "user") 26 | .fin(done) 27 | } 28 | 29 | builder.add(function testSetInvalidReturnValue(test) { 30 | var putBuilder = this.client.putItem('user', { 31 | userId: 'userB', 32 | age: 30 33 | }) 34 | 35 | test.throws(function () { 36 | putBuilder.setReturnValues('ALL_SOMETHING') 37 | }, errors.InvalidReturnValuesError) 38 | 39 | test.done() 40 | }) 41 | 42 | // put an item and check that it exists 43 | builder.add(function testSimplePut(test) { 44 | var self = this 45 | return this.client.putItem("user", { 46 | userId: 'userB', 47 | column: '@', 48 | age: 30 49 | }) 50 | .execute() 51 | .then(function () { 52 | return utils.getItemWithSDK(self.db, "userB", "@") 53 | }) 54 | .then(function (data) { 55 | test.equal(data['Item']['age'].N, "30", "Age should be set") 56 | }) 57 | }) 58 | 59 | builder.add(function testPutItemWithReturnValuesNone(test) { 60 | var self = this 61 | return this.client.putItem("user", { 62 | userId: 'userB', 63 | column: '@', 64 | age: 30 65 | }) 66 | .setReturnValues('NONE') 67 | .execute() 68 | .then(function (data) { 69 | test.equal(data.result, undefined) 70 | 71 | return utils.getItemWithSDK(self.db, "userB", "@") 72 | }) 73 | .then(function (data) { 74 | test.equal(data['Item']['age'].N, "30", "Age should be set") 75 | }) 76 | }) 77 | 78 | // put overrides all fields 79 | builder.add(function testOverridePut(test) { 80 | var self = this 81 | return this.client.putItem("user", { 82 | userId: 'userA', 83 | column: '@', 84 | height: 72 85 | }) 86 | .execute() 87 | .then(function () { 88 | return utils.getItemWithSDK(self.db, "userA", "@") 89 | }) 90 | .then(function (data) { 91 | test.equal(data['Item']['age'], undefined, "Age should be undefined") 92 | test.equal(data['Item']['height'].N, "72", "Height should be 72") 93 | }) 94 | }) 95 | 96 | // put with successful conditional exists 97 | builder.add(function testPutWithConditional(test) { 98 | var self = this 99 | 100 | var conditions = this.client.newConditionBuilder() 101 | .expectAttributeEquals('age', 29) 102 | 103 | return this.client.putItem("user", { 104 | userId: 'userA', 105 | column: '@', 106 | height: 72 107 | }) 108 | .withCondition(conditions) 109 | .execute() 110 | .then(function () { 111 | return utils.getItemWithSDK(self.db, "userA", "@") 112 | }) 113 | .then(function (data) { 114 | test.equal(data['Item']['height'].N, "72", "Height should be 72") 115 | }) 116 | }) 117 | 118 | // put with successful absent conditional exists 119 | builder.add(function testPutWithAbsentConditional(test) { 120 | var self = this 121 | 122 | var conditions = this.client.newConditionBuilder() 123 | .expectAttributeAbsent('height') 124 | 125 | return this.client.putItem("user", { 126 | userId: 'userA', 127 | column: '@', 128 | height: 72 129 | }) 130 | .withCondition(conditions) 131 | .execute() 132 | .then(function () { 133 | return utils.getItemWithSDK(self.db, "userA", "@") 134 | }) 135 | .then(function (data) { 136 | test.equal(data['Item']['height'].N, "72", "Height should be 72") 137 | }) 138 | }) 139 | 140 | // put with successful absent conditional doesn't exist 141 | builder.add(function testPutWithAbsentConditionalAndNoRecord(test) { 142 | var self = this 143 | 144 | var conditions = this.client.newConditionBuilder() 145 | .expectAttributeAbsent('age') 146 | 147 | return this.client.putItem("user", { 148 | userId: 'userB', 149 | column: '@', 150 | height: 72 151 | }) 152 | .withCondition(conditions) 153 | .execute() 154 | .then(function () { 155 | return utils.getItemWithSDK(self.db, "userB", "@") 156 | }) 157 | .then(function (data) { 158 | test.equal(data['Item']['height'].N, "72", "Height should be 72") 159 | }) 160 | }) 161 | 162 | // put with failed conditional exists 163 | builder.add(function testPutWithFailedConditional(test) { 164 | var conditions = this.client.newConditionBuilder() 165 | .expectAttributeEquals('age', 30) 166 | 167 | return this.client.putItem("user", { 168 | userId: 'userA', 169 | column: '@', 170 | height: 72 171 | }) 172 | .withCondition(conditions) 173 | .execute() 174 | .then(function () { 175 | test.fail("'testPutWithFailedConditional' failed") 176 | }) 177 | .fail(this.client.throwUnlessConditionalError) 178 | }) 179 | 180 | // put with failed conditional doesn't exist 181 | builder.add(function testPutWithFailedConditionalForNoRecord(test) { 182 | var conditions = this.client.newConditionBuilder() 183 | .expectAttributeEquals('age', 29) 184 | 185 | return this.client.putItem("user", { 186 | userId: 'userB', 187 | column: '@', 188 | height: 72 189 | }) 190 | .withCondition(conditions) 191 | .execute() 192 | .then(function () { 193 | test.fail("'testPutWithFailedConditionalForNoRecord' failed") 194 | }) 195 | .fail(this.client.throwUnlessConditionalError) 196 | }) 197 | 198 | // put set with failed absent conditional exists 199 | builder.add(function testPutWithFailedAbsentConditionalExists(test) { 200 | var conditions = this.client.newConditionBuilder() 201 | .expectAttributeAbsent('age') 202 | 203 | return this.client.putItem("user", { 204 | userId: 'userA', 205 | column: '@', 206 | height: 72 207 | }) 208 | .withCondition(conditions) 209 | .execute() 210 | .then(function () { 211 | test.fail("'testPutWithFailedAbsentConditionalExists' failed") 212 | }) 213 | .fail(this.client.throwUnlessConditionalError) 214 | }) 215 | 216 | // trigger a conditional error and check its content 217 | builder.add(function testConditionalErrorFormat(test) { 218 | var conditions = this.client.newConditionBuilder() 219 | .expectAttributeAbsent('age') 220 | 221 | return this.client.putItem("user", { 222 | userId: 'userA', 223 | column: '@', 224 | height: 72 225 | }) 226 | .withCondition(conditions) 227 | .execute() 228 | .then(function () { 229 | test.fail('This putItem request should fail due to a conditional error') 230 | }) 231 | .fail(function (err) { 232 | test.ok(err instanceof errors.ConditionalError) 233 | test.equals('The conditional request failed', err.message, 'The "message" field should be the custom message') 234 | test.equals('The conditional request failed', err.details, 'The "details" field should be the custom message') 235 | test.equals('user', err.table, 'The "table" field should be the right table name') 236 | test.ok(!!err.requestId, 'The "requestId" field should exist') 237 | }) 238 | }) 239 | -------------------------------------------------------------------------------- /test/testPutSet.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils/testUtils.js') 2 | var nodeunitq = require('nodeunitq') 3 | var builder = new nodeunitq.Builder(exports) 4 | 5 | var onError = console.error.bind(console) 6 | var initialData = [ {"userId": "userA", "column": "@", "postIds": ['1a', '1b', '1c']} 7 | , {"userId": "userB", "column": "@", "postIds": [1, 2, 3]}] 8 | 9 | /* 10 | * Sets up for test, and creates a record userA with range key @. 11 | */ 12 | exports.setUp = function (done) { 13 | this.db = utils.getMockDatabase() 14 | this.client = utils.getMockDatabaseClient() 15 | utils.ensureLocalDynamo() 16 | utils.createTable(this.db, "user", "userId", "column") 17 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 18 | .fail(onError) 19 | .fin(done) 20 | } 21 | 22 | exports.tearDown = function (done) { 23 | utils.deleteTable(this.db, "user") 24 | .fin(done) 25 | } 26 | 27 | // put a list of strings and check if they exist 28 | builder.add(function testStringSetPut(test) { 29 | var self = this 30 | return this.client.putItem("user", { 31 | userId: 'userC', 32 | column: '@', 33 | postIds: ['3a', '3b', '3c'] 34 | }) 35 | .execute() 36 | .then(function () { 37 | return utils.getItemWithSDK(self.db, "userC", "@") 38 | }) 39 | .then(function (data) { 40 | test.deepEqual(data['Item']['postIds'].SS, ['3a', '3b', '3c'], "postIds should be ['3a', '3b', '3c']") 41 | }) 42 | }) 43 | 44 | // put a list of numbers and check if they exist 45 | builder.add(function testNumberSetPut(test) { 46 | var self = this 47 | return this.client.putItem("user", { 48 | userId: 'userD', 49 | column: '@', 50 | postIds: [1, 2, 3] 51 | }) 52 | .execute() 53 | .then(function () { 54 | return utils.getItemWithSDK(self.db, "userD", "@") 55 | }) 56 | .then(function (data) { 57 | test.deepEqual(data['Item']['postIds'].NS, [1, 2, 3], "postIds should be [1, 2, 3]") 58 | }) 59 | }) 60 | 61 | // override all string set fields 62 | builder.add(function testStringSetPutOverride(test) { 63 | var self = this 64 | return this.client.putItem("user", { 65 | userId: 'userA', 66 | column: '@', 67 | otherIds: ['3a', '3b', '3c'] 68 | }) 69 | .execute() 70 | .then(function () { 71 | return utils.getItemWithSDK(self.db, "userA", "@") 72 | }) 73 | .then(function (data) { 74 | test.equal(data['Item']['postIds'], undefined, "postIds should not exist") 75 | test.deepEqual(data['Item']['otherIds'].SS, ['3a', '3b', '3c'], "otherIds should be ['3a', '3b', '3c']") 76 | }) 77 | }) 78 | 79 | // override all number set fields 80 | builder.add(function testNumberSetPutOverride(test) { 81 | var self = this 82 | return this.client.putItem("user", { 83 | userId: 'userB', 84 | column: '@', 85 | otherIds: [4, 5, 6] 86 | }) 87 | .execute() 88 | .then(function () { 89 | return utils.getItemWithSDK(self.db, "userB", "@") 90 | }) 91 | .then(function (data) { 92 | test.equal(data['Item']['postIds'], undefined, "postIds should not exist") 93 | test.deepEqual(data['Item']['otherIds'].NS, [4, 5, 6], "otherIds should be [4, 5, 6]") 94 | }) 95 | }) 96 | 97 | // override all number set fields with a string set 98 | builder.add(function testNumberSetPutOverrideWithStringSet(test) { 99 | var self = this 100 | return this.client.putItem("user", { 101 | userId: 'userA', 102 | column: '@', 103 | otherIds: [4, 5, 6] 104 | }) 105 | .execute() 106 | .then(function () { 107 | return utils.getItemWithSDK(self.db, "userA", "@") 108 | }) 109 | .then(function (data) { 110 | test.equal(data['Item']['postIds'], undefined, "postIds should not exist") 111 | test.deepEqual(data['Item']['otherIds'].NS, [4, 5, 6], "otherIds should be [4, 5, 6]") 112 | }) 113 | }) 114 | 115 | // put string set with successful conditional exists 116 | builder.add(function testStringSetPutWithConditional(test) { 117 | var self = this 118 | 119 | var conditions = this.client.newConditionBuilder() 120 | .expectAttributeEquals('postIds', ['1a', '1b', '1c']) 121 | 122 | return this.client.putItem("user", { 123 | userId: 'userA', 124 | column: '@', 125 | otherIds: ['5a', '5b', '5c'] 126 | }) 127 | .withCondition(conditions) 128 | .execute() 129 | .then(function () { 130 | return utils.getItemWithSDK(self.db, "userA", "@") 131 | }) 132 | .then(function (data) { 133 | test.equal(data['Item']['otherIds'].SS[0], '5a', "otherIds[0] should be 5a") 134 | }) 135 | }) 136 | 137 | // put string set with successful absent conditional exists 138 | builder.add(function testStringSetPutWithAbsentConditional(test) { 139 | var self = this 140 | 141 | var conditions = this.client.newConditionBuilder() 142 | .expectAttributeAbsent('otherIds') 143 | 144 | return this.client.putItem("user", { 145 | userId: 'userA', 146 | column: '@', 147 | otherIds: ['5a', '5b', '5c'] 148 | }) 149 | .withCondition(conditions) 150 | .execute() 151 | .then(function () { 152 | return utils.getItemWithSDK(self.db, "userA", "@") 153 | }) 154 | .then(function (data) { 155 | test.equal(data['Item']['otherIds'].SS[0], '5a', "otherIds[0] should be 5a") 156 | }) 157 | }) 158 | 159 | // put with successful absent conditional doesn't exist 160 | builder.add(function testStringSetPutWithAbsentConditionalDoesntExist(test) { 161 | var self = this 162 | 163 | var conditions = this.client.newConditionBuilder() 164 | .expectAttributeAbsent('otherIds') 165 | 166 | return this.client.putItem("user", { 167 | userId: 'userA', 168 | column: '@', 169 | otherIds: ['5a', '5b', '5c'] 170 | }) 171 | .withCondition(conditions) 172 | .execute() 173 | .then(function () { 174 | return utils.getItemWithSDK(self.db, "userA", "@") 175 | }) 176 | .then(function (data) { 177 | test.equal(data['Item']['otherIds'].SS[0], '5a', "otherIds[0] should be 5a") 178 | }) 179 | }) 180 | 181 | // put set with failed conditional exists 182 | builder.add(function testStringSetPutWithFailedConditional(test) { 183 | var self = this 184 | 185 | var conditions = this.client.newConditionBuilder() 186 | .expectAttributeEquals('postIds', ['a', 'b', 'c']) 187 | 188 | return this.client.putItem("user", { 189 | userId: 'userA', 190 | column: '@', 191 | otherIds: ['5a', '5b', '5c'] 192 | }) 193 | .withCondition(conditions) 194 | .execute() 195 | .then(function () { 196 | return utils.getItemWithSDK(self.db, "userA", "@") 197 | }) 198 | .then(function () { 199 | test.fail("'testStringSetPutWithFailedConditional' failed") 200 | }) 201 | .fail(this.client.throwUnlessConditionalError) 202 | }) 203 | 204 | // put set with failed conditional doesn't exist 205 | builder.add(function testStringSetPutWithFailedConditionalForNoRecord(test) { 206 | var self = this 207 | 208 | var conditions = this.client.newConditionBuilder() 209 | .expectAttributeEquals('postIds', ['a', 'b', 'c']) 210 | 211 | return this.client.putItem("user", { 212 | userId: 'userC', 213 | column: '@', 214 | otherIds: ['5a', '5b', '5c'] 215 | }) 216 | .withCondition(conditions) 217 | .execute() 218 | .then(function () { 219 | return utils.getItemWithSDK(self.db, "userC", "@") 220 | }) 221 | .then(function () { 222 | test.fail("'testStringSetPutWithFailedConditionalForNoRecord' failed") 223 | }) 224 | .fail(this.client.throwUnlessConditionalError) 225 | }) 226 | 227 | // put set with failed absent conditional exists 228 | builder.add(function testStringSetPutWithFailedConditionalExists(test) { 229 | var self = this 230 | 231 | var conditions = this.client.newConditionBuilder() 232 | .expectAttributeAbsent('postIds', ['1a', '1b', '1c']) 233 | 234 | return this.client.putItem("user", { 235 | userId: 'userA', 236 | column: '@', 237 | otherIds: ['5a', '5b', '5c'] 238 | }) 239 | .withCondition(conditions) 240 | .execute() 241 | .then(function () { 242 | return utils.getItemWithSDK(self.db, "userA", "@") 243 | }) 244 | .then(function () { 245 | test.fail("'testStringSetPutWithFailedConditionalForNoRecord' failed") 246 | }) 247 | .fail(this.client.throwUnlessConditionalError) 248 | }) 249 | -------------------------------------------------------------------------------- /test/testQuery.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var ConditionBuilder = require('../lib/ConditionBuilder') 4 | var utils = require('./utils/testUtils.js') 5 | var nodeunitq = require('nodeunitq') 6 | var builder = new nodeunitq.Builder(exports) 7 | 8 | var onError = console.error.bind(console) 9 | var tableName = "comments" 10 | var rawData = [{"postId": "post1", "column": "@", "title": "This is my post", "content": "And here is some content!", "tags": ['bar', 'foo']}, 11 | {"postId": "post1", "column": "/comment/timestamp/002123", "comment": "this is slightly later"}, 12 | {"postId": "post1", "column": "/comment/timestamp/010000", "comment": "where am I?"}, 13 | {"postId": "post1", "column": "/comment/timestamp/001111", "comment": "HEYYOOOOO"}, 14 | {"postId": "post1", "column": "/comment/timestamp/001112", "comment": "what's up?"}, 15 | {"postId": "post1", "column": "/canEdit/user/AAA", "userId": "AAA"}] 16 | 17 | // sorted data for checking the order of returned data 18 | var sortedRawData = [] 19 | for (var i = 0; i < rawData.length; i++) { 20 | sortedRawData[i] = rawData[i] 21 | } 22 | sortedRawData.sort(function(obj1, obj2) { 23 | return obj1.column > obj2.column ? 1 : -1 24 | }) 25 | 26 | // basic setup for the tests, creating record userA with Index key @ 27 | exports.setUp = function (done) { 28 | this.db = utils.getMockDatabase() 29 | this.client = utils.getMockDatabaseClient() 30 | utils.ensureLocalDynamo() 31 | utils.createTable(this.db, tableName, "postId", "column") 32 | .thenBound(utils.initTable, null, {"db": this.db, "tableName": tableName, "data": rawData}) 33 | .fail(onError) 34 | .fin(done) 35 | } 36 | 37 | exports.tearDown = function (done) { 38 | utils.deleteTable(this.db, tableName) 39 | .fin(done) 40 | } 41 | 42 | function checkResults(test, total, offset) { 43 | return function (data) { 44 | test.equal(data.result.length, total, total + " records should be returned") 45 | for (var i = 0; i < data.result.length; i++) { 46 | test.deepEqual(data.result[i], sortedRawData[i + offset], "Row should be retrieved in the correct order") 47 | } 48 | } 49 | } 50 | 51 | // test basic query 52 | builder.add(function testBasicQuery(test) { 53 | return this.client.newQueryBuilder('comments') 54 | .setHashKey('postId', 'post1') 55 | .execute() 56 | .then(checkResults(test, 6, 0)) 57 | }) 58 | 59 | // test basic query with an empty filter 60 | builder.add(function testBasicQueryEmptyFilter(test) { 61 | return this.client.newQueryBuilder('comments') 62 | .setHashKey('postId', 'post1') 63 | .withFilter(this.client.newConditionBuilder()) 64 | .execute() 65 | .then(checkResults(test, 6, 0)) 66 | }) 67 | 68 | // test Index key begins with 69 | builder.add(function testindexBeginsWith(test) { 70 | return this.client.newQueryBuilder('comments') 71 | .setHashKey('postId', 'post1') 72 | .indexBeginsWith('column', '/comment/') 73 | .execute() 74 | .then(checkResults(test, 4, 1)) 75 | }) 76 | 77 | // test filtering 78 | builder.add(function testFilterByComment(test) { 79 | var filter = this.client.newConditionBuilder() 80 | .filterAttributeBeginsWith("comment", "HEY") 81 | 82 | return this.client.newQueryBuilder('comments') 83 | .setHashKey('postId', 'post1') 84 | .indexBeginsWith('column', '/comment/') 85 | .withFilter(filter) 86 | .execute() 87 | .then(checkResults(test, 1, 1)) 88 | }) 89 | 90 | // test filter with limit 91 | builder.add(function testFilterWithLimit(test) { 92 | var filter = this.client.newConditionBuilder() 93 | .filterAttributeBeginsWith("comment", "wh") 94 | 95 | // The limit parameter is applied before the filter 96 | return this.client.newQueryBuilder('comments') 97 | .setHashKey('postId', 'post1') 98 | .indexBeginsWith('column', '/comment/') 99 | .withFilter(filter) 100 | .setLimit(2) 101 | .execute() 102 | .then(checkResults(test, 1, 2)) 103 | }) 104 | 105 | 106 | 107 | // test Index key between 108 | builder.add(function testIndexKeyBetween(test) { 109 | return this.client.newQueryBuilder('comments') 110 | .setHashKey('postId', 'post1') 111 | .indexBetween('column', '/comment/', '/comment/timestamp/009999') 112 | .execute() 113 | .then(checkResults(test, 3, 1)) 114 | }) 115 | 116 | // test Index key less than 117 | builder.add(function testIndexKeyLessThan(test) { 118 | return this.client.newQueryBuilder('comments') 119 | .setHashKey('postId', 'post1') 120 | .indexLessThan('column', '/comment/timestamp/001111') 121 | .execute() 122 | .then(checkResults(test, 1, 0)) 123 | }) 124 | 125 | // test Index key less than equal 126 | builder.add(function testIndexKeyLessThanEqual(test) { 127 | return this.client.newQueryBuilder('comments') 128 | .setHashKey('postId', 'post1') 129 | .indexLessThanEqual('column', '/comment/timestamp/001111') 130 | .execute() 131 | .then(checkResults(test, 2, 0)) 132 | }) 133 | 134 | // test Index key greater than 135 | builder.add(function testIndexKeyGreaterThan(test) { 136 | return this.client.newQueryBuilder('comments') 137 | .setHashKey('postId', 'post1') 138 | .indexGreaterThan('column', '/comment/timestamp/001111') 139 | .execute() 140 | .then(checkResults(test, 4, 2)) 141 | }) 142 | 143 | // test Index key greater than equal 144 | builder.add(function testIndexKeyGreaterThanEqual(test) { 145 | return this.client.newQueryBuilder('comments') 146 | .setHashKey('postId', 'post1') 147 | .indexGreaterThanEqual('column', '/comment/timestamp/001111') 148 | .execute() 149 | .then(checkResults(test, 5, 1)) 150 | }) 151 | 152 | // test Index key equal 153 | builder.add(function testIndexKeyEqual(test) { 154 | return this.client.newQueryBuilder('comments') 155 | .setHashKey('postId', 'post1') 156 | .indexEqual('column', '/comment/timestamp/001111') 157 | .execute() 158 | .then(checkResults(test, 1, 1)) 159 | }) 160 | 161 | // test limit 162 | builder.add(function testLimit(test) { 163 | return this.client.newQueryBuilder('comments') 164 | .setHashKey('postId', 'post1') 165 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 166 | .setLimit(3) 167 | .execute() 168 | .then(checkResults(test, 3, 1)) 169 | }) 170 | 171 | // test scan forward 172 | builder.add(function testScanForward(test) { 173 | return this.client.newQueryBuilder('comments') 174 | .setHashKey('postId', 'post1') 175 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 176 | .execute() 177 | .then(checkResults(test, 4, 1)) 178 | }) 179 | 180 | // test cursoring forward 181 | builder.add(function testCursorForward(test) { 182 | var client = this.client 183 | 184 | return this.client.newQueryBuilder('comments') 185 | .setHashKey('postId', 'post1') 186 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 187 | .setLimit(3) 188 | .execute() 189 | .then(function (data) { 190 | return client.newQueryBuilder('comments') 191 | .setHashKey('postId', 'post1') 192 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 193 | .setStartKey(data.LastEvaluatedKey) 194 | .execute() 195 | }) 196 | .then(function (data) { 197 | test.equal(data.result.length, 1, "1 record should be returned") 198 | test.equal(data.result[0].comment, "where am I?", "Row comment should be set") 199 | }) 200 | }) 201 | 202 | // test cursoring backward 203 | builder.add(function testCursorBackward(test) { 204 | var client = this.client 205 | 206 | return this.client.newQueryBuilder('comments') 207 | .setHashKey('postId', 'post1') 208 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 209 | .setLimit(3) 210 | .scanBackward() 211 | .execute() 212 | .then(function (data) { 213 | return client.newQueryBuilder('comments') 214 | .setHashKey('postId', 'post1') 215 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 216 | .scanBackward() 217 | .setStartKey(data.LastEvaluatedKey) 218 | .execute() 219 | }) 220 | .then(function (data) { 221 | test.equal(data.result.length, 1, "1 record should be returned") 222 | test.equal(data.result[0].comment, "HEYYOOOOO", "Row comment should be set") 223 | }) 224 | }) 225 | 226 | // test select attributes 227 | builder.add(function testSelectAttributes(test) { 228 | var keyOffset = 1 229 | return this.client.newQueryBuilder('comments') 230 | .setHashKey('postId', 'post1') 231 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 232 | .selectAttributes(['postId', 'comment']) 233 | .execute() 234 | .then(function (data) { 235 | test.equal(data.result.length, 4, "4 records should be returned") 236 | for (var i = 0; i < data.result.length; i++) { 237 | test.equal(data.result[i].comment, sortedRawData[i + keyOffset].comment, "Row comment should be set") 238 | test.equal(data.result[i].column, undefined, 'Column should not be set') 239 | } 240 | }) 241 | }) 242 | 243 | // test set existence 244 | builder.add(function testSetExistence(test) { 245 | return this.client.newQueryBuilder('comments') 246 | .setHashKey('postId', 'post1') 247 | .indexEqual('column', '@') 248 | .selectAttributes(['postId', 'tags']) 249 | .execute() 250 | .then(function (data) { 251 | test.deepEqual(data.result[0].tags, ['bar', 'foo'], "post should have tags ['bar', 'foo']") 252 | }) 253 | }) 254 | 255 | // test scan backward 256 | builder.add(function testScanBackward(test) { 257 | var keyOffset = 1 258 | 259 | return this.client.newQueryBuilder('comments') 260 | .setHashKey('postId', 'post1') 261 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 262 | .scanBackward() 263 | .execute() 264 | .then(function (data) { 265 | test.equal(data.result.length, 4, "4 records should be returned") 266 | for (var i = 0; i < data.result.length; i++) { 267 | test.deepEqual(data.result[i], sortedRawData[(data.result.length - 1 - i) + keyOffset], 268 | "Row should be retrieved in the correct order") 269 | } 270 | }) 271 | }) 272 | 273 | // test count 274 | builder.add(function testCount(test) { 275 | return this.client.newQueryBuilder('comments') 276 | .setHashKey('postId', 'post1') 277 | .indexBetween('column', '/comment/timestamp/002123', '/comment/timestamp/999999') 278 | .getCount() 279 | .execute() 280 | .then(function (data) { 281 | test.equal(data.Count, 2, '"2" should be returned') 282 | }) 283 | }) 284 | 285 | // test count if it's zero 286 | builder.add(function testCountIfZero(test) { 287 | return this.client.newQueryBuilder('comments') 288 | .setHashKey('postId', 'postDNE') 289 | .indexBetween('column', '/comment/timestamp/002123', '/comment/timestamp/999999') 290 | .getCount() 291 | .execute() 292 | .then(function (data) { 293 | test.equal(data.Count, 0, '"0" should be returned') 294 | }) 295 | }) 296 | 297 | builder.add(function testNext(test) { 298 | return this.client.newQueryBuilder('comments') 299 | .setHashKey('postId', 'post1') 300 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 301 | .setLimit(3) 302 | .execute() 303 | .then(function (data) { 304 | test.equal(3, data.Count) 305 | test.ok(data.hasNext()) 306 | return data.next() 307 | }) 308 | .then(function (data) { 309 | test.equal(1, data.Count) 310 | test.ok(!data.hasNext()) 311 | return data.next() 312 | }) 313 | .then(function () { 314 | test.fail('Expected error') 315 | }) 316 | .fail(function (e) { 317 | if (e.message !== 'No more results') throw e 318 | }) 319 | }) 320 | 321 | builder.add(function testNextWithLimit(test) { 322 | return this.client.newQueryBuilder('comments') 323 | .setHashKey('postId', 'post1') 324 | .indexBetween('column', '/comment/', '/comment/timestamp/999999') 325 | .setLimit(2) 326 | .execute() 327 | .then(function (data) { 328 | test.equal(2, data.Count) 329 | test.ok(data.hasNext()) 330 | return data.next(3) 331 | }) 332 | .then(function (data) { 333 | test.equal(2, data.Count) 334 | test.ok(!data.hasNext()) 335 | return data.next() 336 | }) 337 | .then(function () { 338 | test.fail('Expected error') 339 | }) 340 | .fail(function (e) { 341 | if (e.message !== 'No more results') throw e 342 | }) 343 | }) 344 | 345 | builder.add(function testValidationError(test) { 346 | var client = this.client 347 | return client.newQueryBuilder('comments') 348 | .setHashKey('garbage', 'postId') 349 | .execute() 350 | .then(function () { 351 | test.fail('Expected validation exception') 352 | }) 353 | .fail(function (e) { 354 | if (!client.isValidationError(e)) throw e 355 | test.equal('comments', e.table) 356 | }) 357 | }) 358 | 359 | builder.add(function testRetryHandler(test) { 360 | var client = this.client 361 | var calledRetryHandler = 0 362 | 363 | return client.newQueryBuilder('comments') 364 | .setHashKey('invalid', 'post1') 365 | .setRetryHandler(function (method, table, response) { 366 | test.equal(response.error.retryable, false) 367 | ++calledRetryHandler 368 | }) 369 | .execute() 370 | .then(function () { 371 | test.fail('Expected validation to fail!') 372 | }) 373 | .fail(function () { 374 | test.equal(calledRetryHandler, 1) 375 | }) 376 | }) 377 | 378 | 379 | builder.add(function testKeyConditionExpression(test) { 380 | var filter = this.client.newConditionBuilder() 381 | .filterAttributeBeginsWith("comment", "HEY") 382 | 383 | var data = {} 384 | ConditionBuilder.populateExpressionField( 385 | data, 'KeyConditionExpression', [filter], {}) 386 | test.equal('{"#comment":"comment"}', JSON.stringify(data.ExpressionAttributeNames)) 387 | test.equal('{":VC2":{"S":"HEY"}}', JSON.stringify(data.ExpressionAttributeValues)) 388 | test.equal('begins_with(#comment, :VC2)', data.KeyConditionExpression) 389 | test.done() 390 | }) 391 | 392 | 393 | builder.add(function testOrConditionExpression(test) { 394 | var filter1 = this.client.newConditionBuilder() 395 | .filterAttributeBeginsWith("comment", "HEY") 396 | var filter2 = this.client.newConditionBuilder() 397 | .filterAttributeBeginsWith("comment", "what") 398 | 399 | 400 | return this.client.newQueryBuilder('comments') 401 | .setHashKey('postId', 'post1') 402 | .indexBeginsWith('column', '/comment/') 403 | .withFilter(this.client.orConditions([filter1, filter2])) 404 | .execute() 405 | .then(checkResults(test, 2, 1)) 406 | }) 407 | 408 | 409 | builder.add(function testAndConditionExpression(test) { 410 | var filter1 = this.client.newConditionBuilder() 411 | .filterAttributeBeginsWith("comment", "HEY") 412 | var filter2 = this.client.newConditionBuilder() 413 | .filterAttributeEquals("comment", "HEYYOOOOO") 414 | 415 | 416 | return this.client.newQueryBuilder('comments') 417 | .setHashKey('postId', 'post1') 418 | .indexBeginsWith('column', '/comment/') 419 | .withFilter(this.client.andConditions([filter1, filter2])) 420 | .execute() 421 | .then(checkResults(test, 1, 1)) 422 | }) 423 | 424 | 425 | 426 | builder.add(function testNotConditionExpression(test) { 427 | var filter1 = this.client.newConditionBuilder() 428 | .filterAttributeBeginsWith("comment", "HEY") 429 | 430 | 431 | return this.client.newQueryBuilder('comments') 432 | .setHashKey('postId', 'post1') 433 | .indexBeginsWith('column', '/comment/') 434 | .withFilter(this.client.notCondition(filter1)) 435 | .execute() 436 | .then(checkResults(test, 3, 2)) 437 | }) 438 | -------------------------------------------------------------------------------- /test/testScan.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var nodeunitq = require('nodeunitq') 5 | var builder = new nodeunitq.Builder(exports) 6 | 7 | var onError = console.error.bind(console) 8 | var tableName = "user" 9 | var rawData = [{"userId": "a", "column": "@", "post": "3", "email": "1@medium.com"}, 10 | {"userId": "b", "column": "@", "post": "0", "address": "800 Market St. SF, CA"}, 11 | {"userId": "c", "column": "@", "post": "5", "email": "3@medium"}, 12 | {"userId": "d", "column": "@", "post": "2", "twitter": "haha"}, 13 | {"userId": "e", "column": "@", "post": "2", "twitter": "hoho"}, 14 | {"userId": "f", "column": "@", "post": "4", "description": "Designer", "email": "h@w.com"}, 15 | {"userId": "h", "column": "@", "post": "6", "tags": ['bar', 'foo']}] 16 | 17 | 18 | // basic setup for the tests, creating record userA with range key @ 19 | exports.setUp = function (done) { 20 | this.db = utils.getMockDatabase() 21 | this.client = utils.getMockDatabaseClient() 22 | utils.ensureLocalDynamo() 23 | utils.createTable(this.db, tableName, "userId", "column", [{hashKey: "post", hashKeyType: "N"}, {hashKey: "post", hashKeyType: "N", rangeKey: "email"}, {hashKey: "description"}]) 24 | .thenBound(utils.initTable, null, {"db": this.db, "tableName": tableName, "data": rawData}) 25 | .fail(onError) 26 | .fin(done) 27 | } 28 | 29 | exports.tearDown = function (done) { 30 | utils.deleteTable(this.db, tableName) 31 | .fin(done) 32 | } 33 | 34 | /** 35 | * A helper function that runs a query and check the result. 36 | * 37 | * @param query {Query} The query that has been built and ready to execute. 38 | * @param expect {Array} The expected returned results 39 | * @param test {Object} The test object from nodeunit. 40 | * @return {Q} 41 | */ 42 | var scanAndCheck = function (scan, expect, test) { 43 | return scan.execute() 44 | .then(function (data) { 45 | test.equal(data.result.length, expect.length, expect.length + " records should be returned, got " + data.result.length) 46 | data.result.sort(function(a, b) {return (a.userId < b.userId) ? -1 : ((a.userId > b.userId) ? 1 : 0)}) 47 | for (var i = 0; i < data.result.length; i++) { 48 | test.deepEqual(data.result[i], rawData[expect[i]]) 49 | } 50 | }) 51 | } 52 | 53 | // test basic scan on the entire table 54 | builder.add(function testScanAll(test) { 55 | var scan = this.client.newScanBuilder(tableName) 56 | return scanAndCheck(scan, [0, 1, 2, 3, 4, 5, 6], test) 57 | }) 58 | 59 | builder.add(function testScanSegment(test) { 60 | var scan = this.client.newScanBuilder(tableName) 61 | .setParallelScan(0, 2) 62 | return scanAndCheck(scan, [1, 3, 4], test) 63 | }) 64 | 65 | builder.add(function testScanOnGlobalSecondaryIndex(test) { 66 | var scan = this.client.newScanBuilder(tableName) 67 | .setIndexNameGenerator(utils.indexNameGenerator) 68 | .setHashKey('post') 69 | .setRangeKey('email') 70 | return scanAndCheck(scan, [0, 2, 5], test) 71 | }) 72 | 73 | builder.add(function testScanOnGlobalSecondaryIndexWithoutRangeKey(test) { 74 | var scan = this.client.newScanBuilder(tableName) 75 | .setIndexNameGenerator(utils.indexNameGenerator) 76 | .setHashKey('description') 77 | return scanAndCheck(scan, [5], test) 78 | }) 79 | 80 | builder.add(function testParallelScanOnGlobalSecondaryIndex(test) { 81 | var scan = this.client.newScanBuilder(tableName) 82 | .setIndexNameGenerator(utils.indexNameGenerator) 83 | .setHashKey('post') 84 | .setParallelScan(1, 2) 85 | return scanAndCheck(scan, [0, 2, 5, 6], test) 86 | }) 87 | 88 | // test filtering with post == 2 89 | builder.add(function testFilterByEqual(test) { 90 | var scan = this.client.newScanBuilder(tableName) 91 | .withFilter(this.client.newConditionBuilder().filterAttributeEquals("post", 2)) 92 | return scanAndCheck(scan, [3, 4], test) 93 | }) 94 | 95 | // test filtering with post != 2 96 | builder.add(function testFilterByNotEqual(test) { 97 | var scan = this.client.newScanBuilder(tableName) 98 | .withFilter(this.client.newConditionBuilder().filterAttributeNotEquals("post", 2)) 99 | return scanAndCheck(scan, [0, 1, 2, 5, 6], test) 100 | }) 101 | 102 | // test filtering with post <= 2 103 | builder.add(function testFilterByLessThanEqual(test) { 104 | var scan = this.client.newScanBuilder(tableName) 105 | .withFilter(this.client.newConditionBuilder().filterAttributeLessThanEqual("post", 2)) 106 | return scanAndCheck(scan, [1, 3, 4], test) 107 | }) 108 | 109 | // test filtering with post < 2 110 | builder.add(function testFilterByLessThan(test) { 111 | var scan = this.client.newScanBuilder(tableName) 112 | .withFilter(this.client.newConditionBuilder().filterAttributeLessThan("post", 2)) 113 | return scanAndCheck(scan, [1], test) 114 | }) 115 | 116 | // test filtering with post >= 2 117 | builder.add(function testFilterByGreaterThanEqual(test) { 118 | var scan = this.client.newScanBuilder(tableName) 119 | .withFilter(this.client.newConditionBuilder().filterAttributeGreaterThanEqual("post", 2)) 120 | return scanAndCheck(scan, [0, 2, 3, 4, 5, 6], test) 121 | }) 122 | 123 | // test filtering with post > 2 124 | builder.add(function testFilterByGreaterThan(test) { 125 | var scan = this.client.newScanBuilder(tableName) 126 | .withFilter(this.client.newConditionBuilder().filterAttributeGreaterThan("post", 2)) 127 | return scanAndCheck(scan, [0, 2, 5, 6], test) 128 | }) 129 | 130 | // test filtering with not null 131 | builder.add(function testFilterByNotNull(test) { 132 | var scan = this.client.newScanBuilder(tableName) 133 | .withFilter(this.client.newConditionBuilder().filterAttributeNotNull("post")) 134 | return scanAndCheck(scan, [0, 1, 2, 3, 4, 5, 6], test) 135 | }) 136 | 137 | // test filtering with email 'CONTAINS' 'medium' 138 | builder.add(function testFilterByContains(test) { 139 | var scan = this.client.newScanBuilder(tableName) 140 | .withFilter(this.client.newConditionBuilder().filterAttributeContains("email", "medium")) 141 | return scanAndCheck(scan, [0, 2], test) 142 | }) 143 | 144 | // test filters with tags 'CONTAINS' 'foo' 145 | builder.add(function testFilterBySetContains(test) { 146 | var scan = this.client.newScanBuilder(tableName) 147 | .withFilter(this.client.newConditionBuilder().filterAttributeContains("tags", "foo")) 148 | return scanAndCheck(scan, [6], test) 149 | }) 150 | 151 | // test filtering with email 'NOT_CONTAINS' 'medium' 152 | builder.add(function testFilterByNotContains(test) { 153 | var scan = this.client.newScanBuilder(tableName) 154 | .withFilter(this.client.newConditionBuilder().filterAttributeNotContains("email", "medium")) 155 | return scanAndCheck(scan, [5], test, "testFilterByNotContains") 156 | }) 157 | 158 | // test filters with tags 'NOT_CONTAINS' 'baz' 159 | builder.add(function testFilterBySetNotContains(test) { 160 | var scan = this.client.newScanBuilder(tableName) 161 | .withFilter(this.client.newConditionBuilder().filterAttributeNotContains("tags", "baz")) 162 | return scanAndCheck(scan, [6], test, "testFilterBySetNotContains") 163 | }) 164 | 165 | // test filtering with twitter 'BEGIN_WITH' 'h' 166 | builder.add(function testFilterByBeginWith(test) { 167 | var scan = this.client.newScanBuilder(tableName) 168 | .withFilter(this.client.newConditionBuilder().filterAttributeBeginsWith("twitter", "h")) 169 | return scanAndCheck(scan, [3, 4], test, "testFilterByBeginWith") 170 | }) 171 | 172 | // test filtering with post 'BETWEEN' 2 3 173 | builder.add(function testFilterByBetween(test) { 174 | var scan = this.client.newScanBuilder(tableName) 175 | .withFilter(this.client.newConditionBuilder().filterAttributeBetween("post", 2, 3)) 176 | return scanAndCheck(scan, [0, 3, 4], test, "testFilterByBetween") 177 | }) 178 | 179 | // test filtering with post 'IN' 2 3 180 | builder.add(function testFilterByIn(test) { 181 | var scan = this.client.newScanBuilder(tableName) 182 | .withFilter(this.client.newConditionBuilder().filterAttributeIn("post", [2, 3])) 183 | return scanAndCheck(scan, [0, 3, 4], test, "testFilterByIn") 184 | }) 185 | 186 | builder.add(function testNext(test) { 187 | var numInFirstScan = 0 188 | return this.client.newScanBuilder(tableName) 189 | .withFilter(this.client.newConditionBuilder().filterAttributeGreaterThan("post", 2)) 190 | // The limit is *not* the number of records to return; instead it is 191 | // the number of records to scan. So the actual number of records returned 192 | // is not specified when a filter is given. 193 | .setLimit(4) 194 | .execute() 195 | .then(function (data) { 196 | numInFirstScan = data.Count 197 | test.ok(data.hasNext()) 198 | return data.next() 199 | }) 200 | .then(function (data) { 201 | test.equal(4, numInFirstScan + data.Count, 'Scan should return 4 records in total') 202 | test.ok(!data.hasNext()) 203 | return data.next() 204 | }) 205 | .then(function () { 206 | test.fail('Expected error') 207 | }) 208 | .fail(function (e) { 209 | if (e.message !== 'No more results') throw e 210 | }) 211 | }) 212 | -------------------------------------------------------------------------------- /test/testStringSet.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils/testUtils.js') 2 | var nodeunitq = require('nodeunitq') 3 | var builder = new nodeunitq.Builder(exports) 4 | 5 | var onError = console.error.bind(console) 6 | var initialData = [ {"userId": "userA", "column": "@", "postIds": ['1a', '1b', '1c']} 7 | , {"userId": "userB", "column": "@", "postIds": [1, 2, 3]}] 8 | 9 | /* 10 | * Sets up for test, and creates a record userA with range key @. 11 | */ 12 | exports.setUp = function (done) { 13 | this.db = utils.getMockDatabase() 14 | this.client = utils.getMockDatabaseClient() 15 | utils.ensureLocalDynamo() 16 | utils.createTable(this.db, "user", "userId", "column") 17 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 18 | .fail(onError) 19 | .fin(done) 20 | } 21 | 22 | exports.tearDown = function (done) { 23 | utils.deleteTable(this.db, "user") 24 | .fin(done) 25 | } 26 | 27 | // put a list of strings and check if they exist 28 | builder.add(function testStringSetPut(test) { 29 | var self = this 30 | return this.client.putItem("user", { 31 | userId: 'userC', 32 | column: '@', 33 | postIds: ['3a', '3b', '3c'] 34 | }) 35 | .execute() 36 | .then(function () { 37 | return utils.getItemWithSDK(self.db, "userC", "@") 38 | }) 39 | .then(function (data) { 40 | test.deepEqual(data['Item']['postIds'].SS, ['3a', '3b', '3c'], "postIds should be ['3a', '3b', '3c']") 41 | }) 42 | }) 43 | 44 | // put a list of numbers and check if they exist 45 | builder.add(function testNumberSetPut(test) { 46 | var self = this 47 | return this.client.putItem("user", { 48 | userId: 'userD', 49 | column: '@', 50 | postIds: [1, 2, 3] 51 | }) 52 | .execute() 53 | .then(function () { 54 | return utils.getItemWithSDK(self.db, "userD", "@") 55 | }) 56 | .then(function (data) { 57 | test.deepEqual(data['Item']['postIds'].NS, [1, 2, 3], "postIds should be [1, 2, 3]") 58 | }) 59 | }) 60 | 61 | // get the set of strings 62 | builder.add(function testStringSetRetrieve(test) { 63 | return this.client.getItem('user') 64 | .setHashKey('userId', 'userA') 65 | .setRangeKey('column', '@') 66 | .execute() 67 | .then(function (data) { 68 | test.deepEqual(data.result.postIds, ['1a', '1b', '1c'], "postIds should be ['1a', '1b', '1c']") 69 | }) 70 | }) 71 | 72 | // get the set of numbers 73 | builder.add(function testNumberSetRetrieve(test) { 74 | return this.client.getItem('user') 75 | .setHashKey('userId', 'userB') 76 | .setRangeKey('column', '@') 77 | .execute() 78 | .then(function (data) { 79 | test.deepEqual(data.result.postIds, [1, 2, 3], "postIds should be [1, 2, 3]") 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /test/testTypeUtil.js: -------------------------------------------------------------------------------- 1 | // Copyright 2015 A Medium Corporation. 2 | 3 | var typeUtil = require('../lib/typeUtil') 4 | var nodeunitq = require('nodeunitq') 5 | var builder = new nodeunitq.Builder(exports) 6 | var Q = require('kew') 7 | 8 | builder.add(function testAddToSet(test) { 9 | var set = typeUtil.valueToObject([1,2,3]) 10 | test.equal(typeUtil.objectToType(set), 'NS') 11 | 12 | var additions = typeUtil.valueToObject([4]) 13 | test.equal(typeUtil.objectToType(additions), 'NS') 14 | 15 | var modified = typeUtil.addToSet(set, additions) 16 | test.equal(typeUtil.objectToType(modified), 'NS') 17 | modified.NS.sort() 18 | 19 | test.deepEqual(set.NS, [1,2,3].map(String)) 20 | test.deepEqual(modified.NS, [1,2,3,4].map(String)) 21 | 22 | return Q.resolve() 23 | }) 24 | 25 | builder.add(function testAddToNullSet(test) { 26 | var set = null 27 | 28 | var additions = typeUtil.valueToObject([4]) 29 | test.equal(typeUtil.objectToType(additions), 'NS') 30 | 31 | var modified = typeUtil.addToSet(set, additions) 32 | test.equal(typeUtil.objectToType(modified), 'NS') 33 | modified.NS.sort() 34 | 35 | test.deepEqual(modified.NS, [4].map(String)) 36 | 37 | return Q.resolve() 38 | }) 39 | 40 | builder.add(function testDeleteFromSet(test) { 41 | var set = typeUtil.valueToObject([1,2,3]) 42 | test.equal(typeUtil.objectToType(set), 'NS') 43 | 44 | var deletions = typeUtil.valueToObject([1, 4]) 45 | test.equal(typeUtil.objectToType(deletions), 'NS') 46 | 47 | var modified = typeUtil.deleteFromSet(set, deletions) 48 | test.equal(typeUtil.objectToType(modified), 'NS') 49 | modified.NS.sort() 50 | 51 | test.deepEqual(modified.NS, [2, 3].map(String)) 52 | 53 | return Q.resolve() 54 | }) 55 | 56 | builder.add(function testObjectIsNonEmptySet(test) { 57 | test.ok(!typeUtil.objectIsNonEmptySet()) 58 | test.ok(!typeUtil.objectIsNonEmptySet(null)) 59 | test.ok(!typeUtil.objectIsNonEmptySet({})) 60 | test.ok(!typeUtil.objectIsNonEmptySet(typeUtil.valueToObject(4))) 61 | test.ok(!typeUtil.objectIsNonEmptySet(typeUtil.valueToObject('4'))) 62 | 63 | test.ok(typeUtil.objectIsNonEmptySet(typeUtil.valueToObject([4]))) 64 | test.ok(typeUtil.objectIsNonEmptySet(typeUtil.valueToObject(['4']))) 65 | 66 | return Q.resolve() 67 | }) 68 | 69 | builder.add(function testGetAttributeAlias(test) { 70 | var getAlias = typeUtil.getAttributeAlias 71 | test.equal('userId', getAlias('userId')) 72 | test.equal('userId2', getAlias('userId2')) 73 | test.equal('#comment', getAlias('comment')) // reserved word 74 | test.equal('#5f5f757365724964', getAlias('__userId')) 75 | test.equal('#7573657249645f', getAlias('userId_')) 76 | test.equal('#30757365724964', getAlias('0userId')) 77 | return Q.resolve() 78 | }) 79 | -------------------------------------------------------------------------------- /test/testUpdateItem.js: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Obvious Corporation. 2 | 3 | var utils = require('./utils/testUtils.js') 4 | var errors = require('../lib/errors') 5 | var nodeunitq = require('nodeunitq') 6 | var builder = new nodeunitq.Builder(exports) 7 | 8 | var onError = console.error.bind(console) 9 | var initialData = [{ 10 | "userId": "userA", 11 | "column": "@", 12 | "age": "29", 13 | "someStringSet": ['a', 'b', 'c'] 14 | }] 15 | 16 | // basic setup for the tests, creating record userA with range key @ 17 | exports.setUp = function (done) { 18 | this.db = utils.getMockDatabase() 19 | this.client = utils.getMockDatabaseClient() 20 | utils.ensureLocalDynamo() 21 | utils.createTable(this.db, "user", "userId", "column") 22 | .thenBound(utils.initTable, null, {db: this.db, tableName: "user", data: initialData}) 23 | .fail(onError) 24 | .fin(done) 25 | } 26 | 27 | exports.tearDown = function (done) { 28 | utils.deleteTable(this.db, "user") 29 | .fin(done) 30 | } 31 | 32 | builder.add(function testSetInvalidReturnValue(test) { 33 | var updateBuilder = this.client.newUpdateBuilder('user') 34 | 35 | test.throws(function () { 36 | updateBuilder.setReturnValues('ALL_SOMETHING') 37 | }, errors.InvalidReturnValuesError) 38 | 39 | test.done() 40 | }) 41 | 42 | // test putting an attribute for an existing record 43 | builder.add(function testPutAttributeExisting(test) { 44 | var self = this 45 | 46 | return this.client.newUpdateBuilder('user') 47 | .setHashKey('userId', 'userA') 48 | .setRangeKey('column', '@') 49 | .enableUpsert() 50 | .putAttribute('age', 30) 51 | .putAttribute('height', 72) 52 | .execute() 53 | .then(function (data) { 54 | test.equal(data.result.age, 30, 'result age should be 30') 55 | test.equal(data.result.height, 72, 'result height should be 72') 56 | test.equal(data.previous.age, 29, 'previous age should be 29') 57 | test.equal(data.previous.height, undefined, 'previous height should be undefined') 58 | return utils.getItemWithSDK(self.db, "userA", "@") 59 | }) 60 | .then(function (data) { 61 | test.equal(data['Item']['age'].N, "30", "result age should be 30") 62 | test.equal(data['Item']['height'].N, "72", "height should be 72") 63 | }) 64 | }) 65 | 66 | // test putting an attribute for a non-existing record 67 | builder.add(function testPutAttributeNonExisting(test) { 68 | var self = this 69 | 70 | return this.client.newUpdateBuilder('user') 71 | .setHashKey('userId', 'userB') 72 | .setRangeKey('column', '@') 73 | .enableUpsert() 74 | .putAttribute('age', 30) 75 | .putAttribute('height', 72) 76 | .execute() 77 | .then(function (data) { 78 | test.equal(data.previous, null, 'previous should be null') 79 | test.equal(data.result.age, 30, 'result age should be 30') 80 | test.equal(data.result.height, 72, 'result height should be 72') 81 | return utils.getItemWithSDK(self.db, "userB", "@") 82 | }) 83 | .then(function (data) { 84 | test.equal(data['Item']['age'].N, "30", "result age should be 30") 85 | test.equal(data['Item']['height'].N, "72", "Height should be 72") 86 | }) 87 | }) 88 | 89 | //test putting attributes with empty would succeed 90 | builder.add(function testPutAttributeEmpty(test) { 91 | return this.client.newUpdateBuilder('user') 92 | .setHashKey('userId', 'userA') 93 | .setRangeKey('column', '@') 94 | .enableUpsert() 95 | .putAttribute('name', '') 96 | .execute() 97 | .then(function () { 98 | test.fail("'testPutAttributeEmpty' failed - the query is expected to fail, but it didn't.") 99 | }) 100 | .fail(function (e) { 101 | test.equal(e.message.indexOf('An AttributeValue may not contain an empty') !== -1, true, "Conditional request should fail") 102 | }) 103 | }) 104 | 105 | // test adding an attribute for an existing record 106 | builder.add(function testAddAttributeExisting(test) { 107 | var self = this 108 | 109 | return this.client.newUpdateBuilder('user') 110 | .setHashKey('userId', 'userA') 111 | .setRangeKey('column', '@') 112 | .enableUpsert() 113 | .addToAttribute('age', 1) 114 | .addToAttribute('views', -1) 115 | .execute() 116 | .then(function (data) { 117 | test.equal(data.result.age, 30, 'result age should be 30') 118 | test.equal(data.result.views, -1, 'result views should be -1') 119 | test.equal(data.previous.age, 29, 'previous age should be 29') 120 | test.equal(data.previous.views, undefined, 'previous views should be undefined') 121 | return utils.getItemWithSDK(self.db, "userA", "@") 122 | }) 123 | .then(function (data) { 124 | test.equal(data['Item']['age'].N, "30", "result age should be 30") 125 | test.equal(data['Item']['views'].N, "-1", "views should be -1") 126 | }) 127 | }) 128 | 129 | // test adding an attribute for a non-existing record 130 | builder.add(function testAddAttributeNonExisting(test) { 131 | var self = this 132 | 133 | return this.client.newUpdateBuilder('user') 134 | .setHashKey('userId', 'userB') 135 | .setRangeKey('column', '@') 136 | .enableUpsert() 137 | .addToAttribute('age', 1) 138 | .addToAttribute('views', -1) 139 | .execute() 140 | .then(function (data) { 141 | test.equal(data.result.age, 1, 'result age should be 30') 142 | test.equal(data.result.views, -1, 'views should be -1') 143 | return utils.getItemWithSDK(self.db, "userB", "@") 144 | }) 145 | .then(function (data) { 146 | test.equal(data['Item']['age'].N, "1", "result age should be 1") 147 | test.equal(data['Item']['views'].N, "-1", "views should be -1") 148 | }) 149 | }) 150 | 151 | // test deleting an attribute for an existing record 152 | builder.add(function testDeleteAttributeExisting(test) { 153 | var self = this 154 | 155 | return this.client.newUpdateBuilder('user') 156 | .setHashKey('userId', 'userA') 157 | .setRangeKey('column', '@') 158 | .enableUpsert() 159 | .deleteAttribute('age') 160 | .deleteAttribute('height') 161 | .deleteFromAttribute('someStringSet', ['b', 'c', 'd']) 162 | .execute() 163 | .then(function (data) { 164 | test.equal(data.result.age, undefined, 'result age should be undefined') 165 | test.equal(data.result.height, undefined, 'height should be undefined') 166 | test.deepEqual(data.result.someStringSet, ['a'], 'someStringSet should contain "a"') 167 | test.equal(data.previous.age, 29, 'previous age should be 29') 168 | test.equal(data.previous.height, undefined, 'height should be undefined') 169 | test.deepEqual(data.previous.someStringSet, ['a', 'b', 'c'], 'someStringSet should contain "a", "b", "c"') 170 | return utils.getItemWithSDK(self.db, "userA", "@") 171 | }) 172 | .then(function (data) { 173 | test.equal(data['Item']['age'], undefined, 'result age should be undefined') 174 | test.equal(data['Item']['height'], undefined, 'height should be undefined') 175 | test.deepEqual(data['Item']['someStringSet'].SS, ['a'], 'someStringSet should contain only "a"') 176 | }) 177 | }) 178 | 179 | builder.add(function testDeleteAllItemsFromStringSet(test) { 180 | var self = this 181 | 182 | return this.client.newUpdateBuilder('user') 183 | .setHashKey('userId', 'userA') 184 | .setRangeKey('column', '@') 185 | .enableUpsert() 186 | .deleteFromAttribute('someStringSet', ['a', 'b', 'c', 'd']) 187 | .execute() 188 | .then(function (data) { 189 | test.deepEqual(data.result.someStringSet, undefined, 'someStringSet should be undefined') 190 | return utils.getItemWithSDK(self.db, "userA", "@") 191 | }) 192 | .then(function (data) { 193 | test.deepEqual(data['Item']['someStringSet'], undefined, 'someStringSet should be undefined') 194 | }) 195 | }) 196 | 197 | // test deleting an attribute for a non-existing record 198 | builder.add(function testDeleteAttributeNonExisting(test) { 199 | var self = this 200 | 201 | return this.client.newUpdateBuilder('user') 202 | .setHashKey('userId', 'userA') 203 | .setRangeKey('column', '@') 204 | .enableUpsert() 205 | .deleteAttribute('age') 206 | .deleteAttribute('height') 207 | .execute() 208 | .then(function (data) { 209 | // The data from AWS SDK is something like this: 210 | // { ConsumedCapacityUnits: 1, 211 | // LastEvaluatedKey: undefined, 212 | // Count: undefined } 213 | // whereas the data from dynamo-client is like this: 214 | // { ConsumedCapacityUnits: 1, 215 | // LastEvaluatedKey: undefined, 216 | // Count: undefined, 217 | // result: {} } 218 | // so the original testing code is: 219 | // test.deepEqual(data.result, {}, 'result should be undefined') 220 | test.deepEqual(data.result, {userId: 'userA', column: '@', someStringSet: ['a', 'b', 'c']}, 'fields should be updated') 221 | return utils.getItemWithSDK(self.db, "userB", "@") 222 | }) 223 | .then(function (data) { 224 | test.equal(data['Item'], undefined, "userB with range key @ should be undefined") 225 | }) 226 | }) 227 | 228 | // test updating with conditional exists 229 | builder.add(function testUpdateWithConditional(test) { 230 | var self = this 231 | 232 | var conditions = this.client.newConditionBuilder() 233 | .expectAttributeEquals('column', '@') 234 | 235 | return this.client.newUpdateBuilder('user') 236 | .setHashKey('userId', 'userA') 237 | .setRangeKey('column', '@') 238 | .withCondition(conditions) 239 | .putAttribute('age', 30) 240 | .putAttribute('height', 72) 241 | .execute() 242 | .then(function (data) { 243 | test.equal(data.result.age, 30, 'result age should be 30') 244 | test.equal(data.result.height, 72, 'height should be 72') 245 | return utils.getItemWithSDK(self.db, "userA", "@") 246 | }) 247 | .then(function (data) { 248 | test.equal(data['Item']['age'].N, "30", 'result age should be 30') 249 | test.equal(data['Item']['height'].N, "72", 'height should be 72') 250 | }) 251 | }) 252 | 253 | // test updating with returnValues set to NONE 254 | builder.add(function testUpdateWithReturnValuesNone(test) { 255 | var self = this 256 | 257 | return this.client.newUpdateBuilder('user') 258 | .setHashKey('userId', 'userA') 259 | .setRangeKey('column', '@') 260 | .enableUpsert() 261 | .putAttribute('age', 30) 262 | .putAttribute('height', 72) 263 | .setReturnValues('NONE') 264 | .execute() 265 | .then(function (data) { 266 | test.equal(data.result, undefined) 267 | 268 | return utils.getItemWithSDK(self.db, "userA", "@") 269 | }) 270 | .then(function (data) { 271 | test.equal(data['Item']['age'].N, "30", 'result age should be 30') 272 | test.equal(data['Item']['height'].N, "72", 'height should be 72') 273 | }) 274 | }) 275 | 276 | // test updating with absent conditional exists 277 | builder.add(function testUpdateWithAbsentConditionalExists(test) { 278 | var self = this 279 | 280 | var conditions = this.client.newConditionBuilder() 281 | .expectAttributeAbsent('height') 282 | 283 | return this.client.newUpdateBuilder('user') 284 | .setHashKey('userId', 'userA') 285 | .setRangeKey('column', '@') 286 | .withCondition(conditions) 287 | .putAttribute('age', 30) 288 | .putAttribute('height', 72) 289 | .execute() 290 | .then(function (data) { 291 | test.equal(data.result.age, 30, 'result age should be 30') 292 | test.equal(data.result.height, 72, 'height should be 72') 293 | return utils.getItemWithSDK(self.db, "userA", "@") 294 | }) 295 | .then(function (data) { 296 | test.equal(data['Item']['age'].N, "30", 'result age should be 30') 297 | test.equal(data['Item']['height'].N, "72", 'height should be 72') 298 | }) 299 | }) 300 | 301 | // test updating with absent conditional doesn't exist 302 | builder.add(function testUpdateWithAbsentConditionalDoesNotExist(test) { 303 | var self = this 304 | 305 | var conditions = this.client.newConditionBuilder() 306 | .expectAttributeAbsent('height') 307 | 308 | return this.client.newUpdateBuilder('user') 309 | .setHashKey('userId', 'userB') 310 | .setRangeKey('column', '@') 311 | .withCondition(conditions) 312 | .putAttribute('age', 30) 313 | .putAttribute('height', 72) 314 | .execute() 315 | .then(function (data) { 316 | test.equal(data.result.age, 30, 'result age should be 30') 317 | test.equal(data.result.height, 72, 'height should be 72') 318 | return utils.getItemWithSDK(self.db, "userB", "@") 319 | }) 320 | .then(function (data) { 321 | test.equal(data['Item']['age'].N, "30", 'result age should be 30') 322 | test.equal(data['Item']['height'].N, "72", 'height should be 72') 323 | }) 324 | }) 325 | 326 | // Test that deleting from a non-existant record upserts a new item 327 | builder.add(function testUpdateWithDeleteAttributeDoesNotExist(test) { 328 | var self = this 329 | 330 | return this.client.newUpdateBuilder('user') 331 | .setHashKey('userId', 'userC') 332 | .setRangeKey('column', '@') 333 | .enableUpsert() 334 | .deleteAttribute('age') 335 | .deleteAttribute('height') 336 | .execute() 337 | .then(function (data) { 338 | test.equal(data.result.userId, 'userC', 'userId should be set') 339 | return utils.getItemWithSDK(self.db, "userC", "@") 340 | }) 341 | .then(function (data) { 342 | test.equal(data.Item.userId.S, 'userC', 'userId should be set') 343 | }) 344 | }) 345 | 346 | // test updating fails with conditional exists 347 | builder.add(function testUpdateFailsWithConditional(test) { 348 | var conditions = this.client.newConditionBuilder() 349 | .expectAttributeEquals('age', 30) 350 | 351 | return this.client.newUpdateBuilder('user') 352 | .setHashKey('userId', 'userA') 353 | .setRangeKey('column', '@') 354 | .withCondition(conditions) 355 | .putAttribute('age', 30) 356 | .putAttribute('height', 72) 357 | .execute() 358 | .then(function () { 359 | test.fail("'testUpdateFailsWithConditional' failed - the query is expected to fail, but it didn't.") 360 | }) 361 | .fail(this.client.throwUnlessConditionalError) 362 | }) 363 | 364 | // test updating fails with conditional doesnt exist 365 | builder.add(function testUpdateFailsWithConditionalDoesNotExist(test) { 366 | var conditions = this.client.newConditionBuilder() 367 | .expectAttributeEquals('age', 30) 368 | 369 | return this.client.newUpdateBuilder('user') 370 | .setHashKey('userId', 'userB') 371 | .setRangeKey('column', '@') 372 | .withCondition(conditions) 373 | .putAttribute('age', 30) 374 | .putAttribute('height', 72) 375 | .execute() 376 | .then(function () { 377 | test.fail("'testUpdateFailsWithConditionalDoesNotExist' failed - the query is expected to fail, but it didn't.") 378 | }) 379 | .fail(this.client.throwUnlessConditionalError) 380 | }) 381 | 382 | // test updating fails with absent conditional exists 383 | builder.add(function testUpdateFailsWithAbsentConditional(test) { 384 | var conditions = this.client.newConditionBuilder() 385 | .expectAttributeAbsent('age') 386 | 387 | return this.client.newUpdateBuilder('user') 388 | .setHashKey('userId', 'userA') 389 | .setRangeKey('column', '@') 390 | .withCondition(conditions) 391 | .putAttribute('age', 30) 392 | .putAttribute('height', 72) 393 | .execute() 394 | .then(function () { 395 | test.fail("'testUpdateFailsWithAbsentConditional' failed - the query is expected to fail, but it didn't.") 396 | }) 397 | .fail(this.client.throwUnlessConditionalError) 398 | }) 399 | 400 | builder.add(function testUpdateFailsWhenConditionalArgumentBad(test) { 401 | try { 402 | this.client.newUpdateBuilder('user') 403 | .setHashKey('userId', 'userA') 404 | .setRangeKey('column', '@') 405 | .withCondition({age: null}) 406 | .putAttribute('age', 30) 407 | .execute() 408 | test.fail('Expected error') 409 | } catch (e) { 410 | if (!/Expected ConditionBuilder/.test(e.message)) { 411 | throw e 412 | } 413 | } 414 | test.done() 415 | }) 416 | 417 | builder.add(function testPutAttributeWithUnderscores(test) { 418 | var self = this 419 | 420 | return this.client.newUpdateBuilder('user') 421 | .setHashKey('userId', 'userA') 422 | .setRangeKey('column', '@') 423 | .enableUpsert() 424 | .putAttribute('__age', 30) 425 | .putAttribute('00height', 72) 426 | .execute() 427 | .then(function (data) { 428 | test.equal(data.result.__age, 30, 'result __age should be 30') 429 | test.equal(data.result['00height'], 72, 'result 00height should be 72') 430 | return utils.getItemWithSDK(self.db, "userA", "@") 431 | }) 432 | .then(function (data) { 433 | test.equal(data['Item']['__age'].N, "30", "result age should be 30") 434 | test.equal(data['Item']['00height'].N, "72", "height should be 72") 435 | }) 436 | }) 437 | -------------------------------------------------------------------------------- /test/utils/testUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides ultility function for unit testing. 3 | * 4 | * @module utils 5 | **/ 6 | var AWS = require('aws-sdk') 7 | var localDynamo = require('local-dynamo') 8 | var Q = require('kew') 9 | var dynamite = require('../../dynamite') 10 | var AWSName = require('../../lib/common').AWSName 11 | 12 | var utils = {} 13 | 14 | var apiVersion = AWSName.API_VERSION_2012 15 | 16 | // These options make dynamite connect to the fake Dynamo DB instance. 17 | // The good thing here is that we can initialize a dynamite.Client 18 | // using the exact same way as we use in production. 19 | var options = { 20 | apiVersion: apiVersion, 21 | sslEnabled: false, 22 | endpoint: 'localhost:4567', 23 | accessKeyId: 'xxx', 24 | secretAccessKey: 'xxx', 25 | region: 'xxx', 26 | retryHandler: function (method, table, response) { 27 | console.log('retrying', method, table, response) 28 | } 29 | } 30 | 31 | utils.getMockDatabase = function () { 32 | AWS.config.update(options) 33 | return new AWS.DynamoDB() 34 | } 35 | 36 | utils.getMockDatabaseClient = function () { 37 | return new dynamite.Client(options) 38 | } 39 | 40 | var localDynamoProc = null 41 | utils.ensureLocalDynamo = function () { 42 | if (!localDynamoProc) { 43 | localDynamoProc = localDynamo.launch({ 44 | port: 4567, 45 | detached: true, 46 | heap: '1g' 47 | }) 48 | localDynamoProc.on('exit', function () { 49 | localDynamoProc = null 50 | }) 51 | localDynamoProc.unref() 52 | } 53 | 54 | return localDynamoProc 55 | } 56 | process.on('exit', function () { 57 | if (localDynamoProc) { 58 | localDynamoProc.kill() 59 | } 60 | }) 61 | 62 | /* 63 | * A helper function that delete the testing table. 64 | * 65 | * @param db {AWS.DynamoDB} The database instance. 66 | * @return {Promise} 67 | */ 68 | utils.deleteTable = function (db, tableName) { 69 | var defer = Q.defer() 70 | db.deleteTable( 71 | {TableName: tableName}, 72 | defer.makeNodeResolver() 73 | ) 74 | return defer.promise 75 | } 76 | 77 | /* 78 | * A helper to generate an index name for testing indices. 79 | * 80 | * @param hashKey {string} 81 | * @param rangeKey {string} 82 | */ 83 | utils.indexNameGenerator = function (hashKey, rangeKey) { 84 | var name = 'index-' + hashKey 85 | if (rangeKey) name = name + '-' + rangeKey 86 | return name 87 | } 88 | 89 | /* 90 | * A helper function that creates the testing table. 91 | * 92 | * @param db {AWS.DynamoDB} The database instance. 93 | * @return {Promise} 94 | */ 95 | utils.createTable = function (db, tableName, hashKey, rangeKey, gsiDefinitions) { 96 | var defer = Q.defer() 97 | var opts = {} 98 | if (apiVersion === AWSName.API_VERSION_2011) { 99 | opts = { 100 | TableName: tableName, 101 | KeySchema: { 102 | HashKeyElement: {AttributeName: hashKey, AttributeType: "S"} 103 | }, 104 | ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1} 105 | } 106 | 107 | if (rangeKey) { 108 | opts.KeySchema.RangeKeyElement = {AttributeName: rangeKey, AttributeType: "S"} 109 | } 110 | 111 | db.createTable(opts, defer.makeNodeResolver()) 112 | } else if (apiVersion === AWSName.API_VERSION_2012) { 113 | var attributeDefinitions = {} 114 | attributeDefinitions[hashKey] = "S" 115 | opts = { 116 | TableName: tableName, 117 | AttributeDefinitions: [], 118 | KeySchema: [ 119 | {AttributeName: hashKey, KeyType: "HASH"} 120 | ], 121 | ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1} 122 | } 123 | 124 | if (rangeKey) { 125 | attributeDefinitions[rangeKey] = "S" 126 | opts.KeySchema.push({ 127 | AttributeName: rangeKey, 128 | KeyType: "RANGE" 129 | }) 130 | } 131 | 132 | if (gsiDefinitions) { 133 | opts.GlobalSecondaryIndexes = gsiDefinitions.map(function (index) { 134 | 135 | var keySchema = [ 136 | {AttributeName: index.hashKey, KeyType: "HASH"} 137 | ] 138 | var hashKeyType = index.hashKeyType || "S" 139 | attributeDefinitions[index.hashKey] = hashKeyType 140 | 141 | if (index.rangeKey) { 142 | var rangeKeyType = index.rangeKeyType || "S" 143 | keySchema.push({AttributeName: index.rangeKey, KeyType: "RANGE"}) 144 | attributeDefinitions[index.rangeKey] = rangeKeyType 145 | } 146 | return { 147 | IndexName: utils.indexNameGenerator(index.hashKey, index.rangeKey), 148 | KeySchema: keySchema, 149 | Projection: { 150 | ProjectionType: "ALL" 151 | }, 152 | ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1} 153 | } 154 | }) 155 | } 156 | 157 | for (var field in attributeDefinitions) { 158 | opts.AttributeDefinitions.push({AttributeName: field, AttributeType: attributeDefinitions[field]}) 159 | } 160 | 161 | db.createTable(opts, defer.makeNodeResolver()) 162 | } else { 163 | defer.reject(new Error('No api version found')) 164 | } 165 | return defer.promise 166 | } 167 | 168 | /* 169 | * A helper function that converts raw data JSON into AWS JSON format. 170 | * 171 | * Example: 172 | * 173 | * raw data JSON: { userId: 'userA', column: '@', age: '29' } 174 | * 175 | * AWS JSON: { userId: { S: 'userA' }, column: { S: '@' }, age: { N: '29' } } 176 | * 177 | * @param obj {Object} The raw JSON data 178 | * @return {Object} The same data in AWS JSON 179 | */ 180 | var convert = function (obj) { 181 | var items = {} 182 | for (var key in obj) { 183 | if (Array.isArray(obj[key]) && isNaN(obj[key][0])) { 184 | items[key] = {"SS": obj[key]} 185 | } else if (Array.isArray(obj[key])) { 186 | var numArray = [] 187 | for (var i in obj[key]) { 188 | numArray.push(String(obj[key][i])) 189 | } 190 | 191 | items[key] = {"NS": numArray} 192 | } else if (isNaN(obj[key])) { 193 | items[key] = {"S": obj[key]} 194 | } else { 195 | items[key] = {"N": obj[key]} 196 | } 197 | } 198 | 199 | return items 200 | } 201 | 202 | /* 203 | * A helper function that incert one record directly using AWS API (not 204 | * our own putItem) 205 | * 206 | * @param db {Object} The database instance 207 | * @param tableName {String} The name of the table to insert 208 | * @param record {Object} The raw JSON data 209 | * @return {Q.Promise} 210 | */ 211 | var putOneRecord = function(db, tableName, record) { 212 | var defer = Q.defer() 213 | db.putItem( 214 | {TableName: tableName, Item: convert(record)}, 215 | defer.makeNodeResolver() 216 | ) 217 | return defer.promise 218 | } 219 | 220 | /* 221 | * A helper function that initializes the testing database with 222 | * some data. 223 | * 224 | * @return {Promise} 225 | */ 226 | utils.initTable = function (context) { 227 | var db = context.db 228 | var promises = [] 229 | for (var i = 0; i < context.data.length; i += 1) { 230 | promises.push(putOneRecord(db, context.tableName, context.data[i])) 231 | } 232 | return Q.all(promises) 233 | } 234 | 235 | /* 236 | * Get a record from the database with the original AWS SDK. 237 | * The reason we don't use dynamite.getItem() here is to focus this test suite 238 | * on putItem(). 239 | * 240 | * @param db {AWS.DynamoDB} The database instance. 241 | * @param hashKey {String} 242 | * @param rangeKey {String} 243 | */ 244 | utils.getItemWithSDK = function (db, hashKey, rangeKey, table) { 245 | var defer = Q.defer() 246 | var opts = {} 247 | 248 | table = table || 'user' 249 | 250 | if (apiVersion === AWSName.API_VERSION_2011) { 251 | opts = { 252 | TableName: table, 253 | Key: { 254 | HashKeyElement: {"S": hashKey} 255 | } 256 | } 257 | 258 | if (rangeKey) { 259 | opts.Key.RangeKeyElement = {"S": rangeKey} 260 | } 261 | 262 | db.getItem( 263 | opts, 264 | defer.makeNodeResolver() 265 | ) 266 | } else if (apiVersion === AWSName.API_VERSION_2012) { 267 | opts = { 268 | TableName: table, 269 | Key: { 270 | userId: {"S": hashKey} 271 | } 272 | } 273 | 274 | if (rangeKey) { 275 | opts.Key.column = {"S": rangeKey} 276 | } 277 | 278 | db.getItem( 279 | opts, 280 | defer.makeNodeResolver() 281 | ) 282 | } 283 | return defer.promise 284 | } 285 | 286 | exports = module.exports = utils 287 | --------------------------------------------------------------------------------