From 6d5e70e4fdcfd5f28058298bc63cf749d15837a9 Mon Sep 17 00:00:00 2001 From: Ryan Fitzgerald Date: Tue, 11 Nov 2014 16:50:29 -0500 Subject: [PATCH] Vogels 2.0 - JSON document support. Closes #50 New Features * Full JSON document support, including support for List, Map, and BOOL datatypes. Closes #16, #44 * New timestamps config option to automatically add the attributes createdAt and updatedAt when defining a model. These attributes will get automatically get set when you create and update a record. * Flexible schema configuration to allow unknown, dynamic attributes both on top level and nested records * Ability to fully configure global and local secondary indexes. Allows to configure index names, attribute projection settings and index throughput. Closes #43 , #48 * adding deleteTable api to remove the table from DynamoDB. Closes #10 * 100% code coverage with new integration test suite and integration with travis-ci. Bug Fixes * CreateTables checks if error occurs while attempting to create a table. Fixes #41 * Fixed error handling when streaming query, scan and parallel scan requests. * Fixed retry handling when running query and scans. Fixes #45 Updated dependencies * Joi to v5.1.0 * aws-sdk to v2.1.5 - Closes #49 * async to v0.9.0 * lodash to v2.4.1 --- .gitignore | 2 + .travis.yml | 13 + Dockerfile | 9 + Gruntfile.js | 64 -- Makefile | 39 + README.md | 235 +++-- examples/addItems.js | 33 +- examples/basic.js | 65 +- examples/batchGet.js | 66 +- examples/binary.js | 31 +- examples/createTable.js | 63 +- examples/dynamicKeys.js | 63 ++ examples/dynamicTableName.js | 23 +- examples/globalSecondaryIndexes.js | 85 +- examples/hooks.js | 48 +- examples/modelMethods.js | 14 +- examples/nestedAttributes.js | 112 +++ examples/optionalAttributes.js | 15 +- examples/parallelscan.js | 51 +- examples/query.js | 77 +- examples/queryFilter.js | 55 +- examples/scan.js | 71 +- examples/schema.js | 58 -- examples/streaming.js | 17 +- examples/update.js | 52 +- fig.yml | 15 + lib/batch.js | 16 +- lib/createTables.js | 8 +- lib/expressions.js | 93 ++ lib/index.js | 42 +- lib/item.js | 4 +- lib/parallelScan.js | 42 +- lib/query.js | 79 +- lib/scan.js | 74 +- lib/schema.js | 250 +++-- lib/serializer.js | 366 +++----- lib/table.js | 158 +++- lib/types/binary.js | 38 - lib/utils.js | 18 +- package.json | 27 +- test/batch-test.js | 229 ++++- test/expressions-test.js | 398 ++++++++ test/index-test.js | 174 +++- test/integration/create-table-test.js | 391 ++++++++ test/integration/integration-test.js | 862 +++++++++++++++++ test/item-test.js | 92 +- test/parallel-test.js | 65 ++ test/query-test.js | 514 ++++++++--- test/scan-test.js | 259 ++++-- test/schema-test.js | 460 +++++---- test/serializer-test.js | 849 ++++++++++------- test/table-test.js | 1230 ++++++++++++++++++------- test/test-helper.js | 46 +- test/types/binary-test.js | 45 - 54 files changed, 6006 insertions(+), 2199 deletions(-) create mode 100644 Dockerfile delete mode 100644 Gruntfile.js create mode 100644 Makefile create mode 100644 examples/dynamicKeys.js create mode 100644 examples/nestedAttributes.js delete mode 100644 examples/schema.js create mode 100644 fig.yml create mode 100644 lib/expressions.js delete mode 100644 lib/types/binary.js create mode 100644 test/expressions-test.js create mode 100644 test/integration/create-table-test.js create mode 100644 test/integration/integration-test.js create mode 100644 test/parallel-test.js delete mode 100644 test/types/binary-test.js diff --git a/.gitignore b/.gitignore index 8bdfc2c..4895e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ Debug/ Release/ npm-debug.log tmp +covreporter/ +coverage/ diff --git a/.travis.yml b/.travis.yml index 6e5919d..e6a0108 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,16 @@ language: node_js node_js: - "0.10" +env: + global: + - AWS_ACCESS_KEY_ID=AKID + - AWS_SECRET_ACCESS_KEY=SECRET + - AWS_REGION=us-east-1 +before_script: + - wget http://dynamodb-local.s3-website-us-west-2.amazonaws.com/dynamodb_local_latest.tar.gz -O /tmp/dynamodb_local_latest.tar.gz + - tar -xzf /tmp/dynamodb_local_latest.tar.gz -C /tmp + - java -Djava.library.path=/tmp/DynamoDBLocal_lib -jar /tmp/DynamoDBLocal.jar -inMemory & + - sleep 2 +addons: + hosts: + - dynamodb-local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..036990e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM google/nodejs + +WORKDIR /app +ADD package.json /app/ +RUN npm install +ADD . /app + +# CMD [] +# ENTRYPOINT ["/nodejs/bin/npm", "test"] diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index c7dc5af..0000000 --- a/Gruntfile.js +++ /dev/null @@ -1,64 +0,0 @@ -'use strict'; - -module.exports = function(grunt) { - - // Project configuration. - grunt.initConfig({ - jshint: { - options: { - jshintrc: '.jshintrc' - }, - gruntfile: { - src: 'Gruntfile.js' - }, - lib: { - src: ['index.js', 'lib/**/*.js'] - }, - test: { - src: ['test/**/*.js'] - }, - examples: { - src: ['examples/**/*.js'] - } - }, - watch: { - gruntfile: { - files: '<%= jshint.gruntfile.src %>', - tasks: ['jshint:gruntfile'] - }, - lib: { - files: '<%= jshint.lib.src %>', - tasks: ['jshint:lib', 'simplemocha'] - }, - test: { - files: '<%= jshint.test.src %>', - tasks: ['jshint:test', 'simplemocha'] - }, - examples : { - files: '<%= jshint.examples.src %>', - tasks: ['jshint:examples'] - } - }, - simplemocha: { - options: { - globals: ['should'], - timeout: 3000, - ignoreLeaks: false, - //grep: '*-test', - ui: 'bdd', - reporter: 'list' - }, - - all: { src: ['<%= jshint.test.src %>'] } - } - }); - - // These plugins provide necessary tasks. - grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-simple-mocha'); - - grunt.registerTask('test', ['simplemocha']); - // Default task. - grunt.registerTask('default', ['jshint', 'simplemocha']); -}; diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d498907 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +REPORTER ?= list +SRC = $(shell find lib -name "*.js" -type f | sort) +TESTSRC = $(shell find test -name "*.js" -type f | sort) + +default: test + +lint: $(SRC) $(TESTSRC) + @node_modules/.bin/jshint --reporter node_modules/jshint-stylish/stylish.js $^ + +test-unit: lint + @node node_modules/.bin/mocha \ + --reporter $(REPORTER) \ + --ui bdd \ + test/*-test.js + +test-integration: lint + @node node_modules/.bin/mocha \ + --reporter spec \ + --ui bdd \ + test/integration/*-test.js + +test-cov: lint + @node node_modules/.bin/mocha \ + -r jscoverage \ + $(TESTSRC) + +test-cov-html: lint + @node node_modules/.bin/mocha \ + -r jscoverage \ + --covout=html \ + $(TESTSRC) + +coverage: lint + @node_modules/.bin/istanbul cover node_modules/mocha/bin/_mocha $(TESTSRC) + @node_modules/.bin/istanbul check-coverage --statements 100 --functions 100 --branches 100 --lines 100 + +test: test-unit test-integration + +.PHONY: test test-cov test-cov-html diff --git a/README.md b/README.md index 41fe2b1..fb58342 100644 --- a/README.md +++ b/README.md @@ -34,22 +34,37 @@ vogels.AWS.config.update({accessKeyId: 'AKID', secretAccessKey: 'SECRET'}); Models are defined through the toplevel define method. ```js -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name').required(); // name attribute is required - schema.Number('age'); // age is optional - schema.Date('created', {default: Date.now}); +var Account = vogels.define('Account', { + hashKey : 'email', + + // add the timestamp attributes (updatedAt, createdAt) + timestamps : true, + + schema : { + email : Joi.string().email(), + name : Joi.string(), + age : Joi.number(), + roles : vogels.types.stringSet(), + settings : { + nickname : Joi.string(), + acceptedTerms : Joi.boolean().default(false) + } + } }); ``` Models can also be defined with hash and range keys. ```js -var BlogPost = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('title', {rangeKey: true}); - schema.String('content'); - schema.StringSet('tags'); +var BlogPost = vogels.define('BlogPost', { + hashKey : 'email', + rangeKey : ‘title’, + schema : { + email : Joi.string().email(), + title : Joi.string(), + content : Joi.binary(), + tags : vogels.types.stringSet(), + } }); ``` @@ -71,10 +86,13 @@ Default, the uuid will be automatically generated when attempting to create the model in DynamoDB. ```js -var Tweet = vogels.define('Account', function (schema) { - schema.UUID('TweetID', {hashKey: true}); - schema.String('content'); - schema.Date('created', {default: Date.now}); +var Tweet = vogels.define('Tweet', { + hashKey : 'TweetID', + timestamps : true, + schema : { + TweetID : vogels.types.uuid(), + content : Joi.string(), + } }); ``` @@ -128,6 +146,17 @@ BlogPost.create({ }); ``` +Use expressions api to do conditional writes + +```js + var params = {}; + params.ConditionExpression = '#i <> :x'; + params.ExpressionAttributeNames = {'#i' : 'id'}; + params.ExpressionAttributeValues = {':x' : 123}; + + User.create({id : 123, name : 'Kurt Warner' }, params, function (error, acc) { +``` + ### Updating When updating a model the hash and range key attributes must be given, all @@ -203,6 +232,29 @@ BlogPost.update({ }); ``` +Use the expressions api to update nested documents + +```js +var params = {}; + params.UpdateExpression = 'SET #year = #year + :inc, #dir.titles = list_append(#dir.titles, :title), #act[0].firstName = :firstName ADD tags :tag'; + params.ConditionExpression = '#year = :current'; + params.ExpressionAttributeNames = { + '#year' : 'releaseYear', + '#dir' : 'director', + '#act' : 'actors' + }; + + params.ExpressionAttributeValues = { + ':inc' : 1, + ':current' : 2001, + ':title' : ['The Man'], + ':firstName' : 'Rob', + ':tag' : vogels.Set(['Sports', 'Horror'], 'S') + }; + +Movie.update({title : 'Movie 0', description : 'This is a description'}, params, function (err, mov) {}); +``` + ### Deleting You delete items in DynamoDB using the hashkey of model If your model uses both a hash and range key, than both need to be provided @@ -234,6 +286,18 @@ Account.destroy('foo@example.com', {expected: {age: 22}}, function (err) { console.log('account deleted if the age was 22'); ``` + +Use expression apis to perform conditional deletes + +```js +var params = {}; +params.ConditionExpression = '#v = :x'; +params.ExpressionAttributeNames = {'#v' : 'version'}; +params.ExpressionAttributeValues = {':x' : '2'}; + +User.destroy({id : 123}, params, function (err, acc) {}); +``` + ### Loading models from DynamoDB The simpliest way to get an item from DynamoDB is by hashkey. @@ -280,6 +344,13 @@ BlogPost.get({email: 'werner@example.com', title: 'Expanding the Cloud'}, functi console.log('loded post', post.get('content')); }); ``` + +Use expressions api to select which attributes you want returned + +```js + User.get({ id : '123456789'},{ ProjectionExpression : 'email, age, settings.nickname' }, function (err, acc) {}); +``` + ### Query For models that use hash and range keys Vogels provides a flexible and chainable query api @@ -387,7 +458,7 @@ BlogPost BlogPost .query('werner@example.com') - .where('title').between(['foo@example.com', 'test@example.com']) + .where('title').between('foo@example.com', 'test@example.com') .exec(); ``` @@ -401,21 +472,38 @@ BlogPost .exec(); ``` +Expression Filters also allow you to further filter results on non-key attributes. + +```javascript +BlogPost + .query('werner@example.com') + .filterExpression('#title < :t') + .expressionAttributeValues({ ':t' : 'Expanding' }) + .expressionAttributeNames({ '#title' : 'title'}) + .projectionExpression('#title, tag') + .exec(); +``` + See the queryFilter.js [example][0] for more examples of using query filters #### Global Indexes First, define a model with a global secondary index. ```js -var GameScore = vogels.define('GameScore', function (schema) { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); - schema.Date('topScoreDateTime'); - schema.Number('wins'); - schema.Number('losses'); - - schema.globalIndex('GameTitleIndex', { hashKey: 'gameTitle', rangeKey: 'topScore'}); +var GameScore = vogels.define('GameScore', { + hashKey : 'userId', + rangeKey : 'gameTitle', + schema : { + userId : Joi.string(), + gameTitle : Joi.string(), + topScore : Joi.number(), + topScoreDateTime : Joi.date(), + wins : Joi.number(), + losses : Joi.number() + }, + indexes : [{ + hashKey : 'gameTitle', rangeKey : 'topScore', name : 'GameTitleIndex', type : 'global' + }] }); ``` @@ -434,19 +522,25 @@ By default all attributes will be projected when no Projection pramater is present ```js -var GameScore = vogels.define('GameScore', function (schema) { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); - schema.Date('topScoreDateTime'); - schema.Number('wins'); - schema.Number('losses'); +var GameScore = vogels.define('GameScore', { + hashKey : 'userId', + rangeKey : 'gameTitle', + schema : { + userId : Joi.string(), + gameTitle : Joi.string(), + topScore : Joi.number(), + topScoreDateTime : Joi.date(), + wins : Joi.number(), + losses : Joi.number() + }, + indexes : [{ + hashKey : 'gameTitle', + rangeKey : 'topScore', + name : 'GameTitleIndex', + type : 'global', + projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } //optional, defaults to ALL - schema.globalIndex('GameTitleIndex', { - hashKey: 'gameTitle', - rangeKey: 'topScore', - Projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } //optional, defaults to ALL - }); + }] }); ``` @@ -467,12 +561,19 @@ GameScore First, define a model using a local secondary index ```js -var BlogPost = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('title', {rangeKey: true}); - schema.String('content'); +var BlogPost = vogels.define('Account', { + hashkey : 'email', + rangekey : 'title', + schema : { + email : Joi.string().email(), + title : Joi.string(), + content : Joi.binary(), + PublishedDateTime : Joi.date() + }, - schema.Date('PublishedDateTime', {secondaryIndex: true}); + indexes : [{ + hashkey : 'email', rangekey : 'PublishedDateTime', type : 'local', name : 'PublishedIndex' + }] }); ``` @@ -481,7 +582,7 @@ Now we can query for blog posts using the secondary index ```js BlogPost .query('werner@example.com') - .usingIndex('PublishedDateTimeIndex') + .usingIndex('PublishedIndex') .descending() .exec(callback); ``` @@ -491,7 +592,7 @@ Could also query for published posts, but this time return oldest first ```js BlogPost .query('werner@example.com') - .usingIndex('PublishedDateTimeIndex') + .usingIndex('PublishedIndex') .ascending() .exec(callback); ``` @@ -500,7 +601,7 @@ Finally lets load all published posts sorted by publish date ```js BlogPost .query('werner@example.com') - .usingIndex('PublishedDateTimeIndex') + .usingIndex('PublishedIndex') .descending() .loadAll() .exec(callback); @@ -640,7 +741,7 @@ Account // between Account .scan() - .where('name').between(['Bar', 'Foo']) + .where('name').between('Bar', 'Foo') .exec(); // multiple filters @@ -651,6 +752,17 @@ Account .exec(); ``` +You can also use the new expressions api when filtering scans + +```javascript +User.scan() + .filterExpression('#age BETWEEN :low AND :high AND begins_with(#email, :e)') + .expressionAttributeValues({ ':low' : 18, ':high' : 22, ':e' : 'test1'}) + .expressionAttributeNames({ '#age' : 'age', '#email' : 'email'}) + .projectionExpression('#age, #email') + .exec(); +``` + ### Parallel Scan Parallel scans increase the throughput of your table scans. The parallel scan operation is identical to the scan api. @@ -734,15 +846,18 @@ querystream.on('end', function () { vogels supports dynamic table names, useful for storing time series data. ```js -var Event = vogels.define('Event', function (schema) { - schema.String('name', {hashKey: true}); - schema.Number('total'); +var Event = vogels.define('Event', { + hashkey : 'name', + schema : { + name : Joi.string(), + total : Joi.number() + }, // store monthly event data - schema.tableName = function () { + tableName: function () { var d = new Date(); return ['events', d.getFullYear(), d.getMonth() + 1].join('_'); - }; + } }); ``` @@ -751,11 +866,17 @@ var Event = vogels.define('Event', function (schema) { ```js var vogels = require('vogels'); -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name').required(); - schema.Number('age'); - schema.Date('created', {default: Date.now}); +var Account = vogels.define('Account', { + hashKey : 'email', + + // add the timestamp attributes (updatedAt, createdAt) + timestamps : true, + + schema : { + email : Joi.string().email(), + name : Joi.string().required(), + age : Joi.number(), + } }); Account.create({email: 'test@example.com', name : 'Test Account'}, function (err, acc) { @@ -776,14 +897,12 @@ See the [examples][0] for more working sample code. * Batch Write Items * Streaming api support for all operations -* DDL operations (update throughput) -* Full intergration test suite ### License (The MIT License) -Copyright (c) 2014 Ryan Fitzgerald +Copyright (c) 2015 Ryan Fitzgerald Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/examples/addItems.js b/examples/addItems.js index 41e6461..31f5d31 100644 --- a/examples/addItems.js +++ b/examples/addItems.js @@ -1,17 +1,32 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + AWS = vogels.AWS, + Joi = require('joi'), + async = require('async'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age', {secondaryIndex: true}); - schema.Date('created', {default: Date.now}); +var Account = vogels.define('example-Account', { + hashKey : 'AccountId', + timestamps : true, + schema : { + AccountId : vogels.types.uuid(), + name : Joi.string(), + email : Joi.string().email(), + age : Joi.number(), + } }); -for (var i = 0; i < 50 ; i++) { - Account.create({name : 'Account ' + i, email : 'account' +i + '@gmail.com', age : i}); -} +vogels.createTables({ + 'example-Account' : {readCapacity: 1, writeCapacity: 10}, +}, function (err) { + if(err) { + console.log('Error creating tables', err); + process.exit(1); + } + + async.times(25, function(n, next) { + Account.create({name : 'Account ' + n, email : 'account' +n + '@gmail.com', age : n}, next); + }); +}); diff --git a/examples/basic.js b/examples/basic.js index b3d3bd6..80c70b6 100644 --- a/examples/basic.js +++ b/examples/basic.js @@ -1,15 +1,27 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + _ = require('lodash'), + util = require('util'), + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - schema.Date('created', {default: Date.now}); +var Account = vogels.define('Foobar', { + hashKey : 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + scores : vogels.types.numberSet(), + created : Joi.date().default(Date.now), + list : Joi.array(), + settings : { + nickname : Joi.string(), + luckyNumber : Joi.number().min(1).default(7) + } + } }); var printAccountInfo = function (err, acc) { @@ -22,17 +34,40 @@ var printAccountInfo = function (err, acc) { } }; -// Simple get request -Account.get('test@example.com', printAccountInfo); +var printScanResults = function (err, data) { + if(err) { + console.log('got scan error', err); + } else if (data.Items) { + var items = _.map(data.Items, function (d) { return d.get(); }); + console.log('scan finished, got ', util.inspect(items, { showHidden: false, depth: null })); + } else { + console.log('scan returned empty result set'); + } +}; + +vogels.createTables(function (err) { + if(err) { + console.log('failed to create table', err); + } -// Consistent Read get request -Account.get('foo@example.com', {ConsistentRead: true}, printAccountInfo); + // Simple get request + Account.get('test11@example.com', printAccountInfo); + Account.get('test@test.com', printAccountInfo); -// Create an account -Account.create({email: 'test11@example.com', name : 'test 11', age: 21}, function (err, acc) { - console.log('account created', acc.get()); + // Create an account + var params = { + email: 'test11@example.com', name : 'test 11', age: 21, scores : [22, 55, 44], + list : ['a', 'b', 'c', 1, 2, 3], + settings : {nickname : 'tester'} + }; - acc.set({name: 'Test 11', age: 25}).update(function (err) { - console.log('account updated', err, acc.get()); + Account.create(params, function (err, acc) { + printAccountInfo(err, acc); + + acc.set({name: 'Test 11', age: 25}).update(function (err) { + console.log('account updated', err, acc.get()); + }); }); + + Account.scan().exec(printScanResults); }); diff --git a/examples/batchGet.js b/examples/batchGet.js index bee396a..a7f4694 100644 --- a/examples/batchGet.js +++ b/examples/batchGet.js @@ -1,15 +1,22 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + async = require('async'), + _ = require('lodash'), + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - schema.Date('created', {default: Date.now}); +var Account = vogels.define('example-batch-get-account', { + hashKey : 'email', + timestamps : true, + schema : { + email : Joi.string().email(), + name : Joi.string(), + age : Joi.number(), + roles : vogels.types.stringSet() + } }); var printAccountInfo = function (err, acc) { @@ -22,23 +29,42 @@ var printAccountInfo = function (err, acc) { } }; -// Get two accounts at once -Account.batchGetItems(['test5@example.com', 'test4@example.com'], function (err, accounts) { - accounts.forEach(function (acc) { - printAccountInfo(null, acc); +var loadSeedData = function (callback) { + callback = callback || _.noop; + + async.times(15, function(n, next) { + var roles = n %3 === 0 ? ['admin', 'editor'] : ['user']; + Account.create({email: 'test' + n + '@example.com', name : 'Test ' + n %3, age: n, roles : roles}, next); + }, callback); +}; + +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { + if(err) { + console.log('error', err); + process.exit(1); + } + + // Get two accounts at once + Account.getItems(['test1@example.com', 'test2@example.com'], function (err, accounts) { + accounts.forEach(function (acc) { + printAccountInfo(null, acc); + }); }); -}); -// Same as above but a strongly consistent read is used -Account.batchGetItems(['test5@example.com', 'test4@example.com'], {ConsistentRead: true}, function (err, accounts) { - accounts.forEach(function (acc) { - printAccountInfo(null, acc); + // Same as above but a strongly consistent read is used + Account.getItems(['test3@example.com', 'test4@example.com'], {ConsistentRead: true}, function (err, accounts) { + accounts.forEach(function (acc) { + printAccountInfo(null, acc); + }); }); -}); -// Get two accounts, but only fetching the age attribute -Account.batchGetItems(['test5@example.com', 'test4@example.com'], {AttributesToGet : ['age']}, function (err, accounts) { - accounts.forEach(function (acc) { - printAccountInfo(null, acc); + // Get two accounts, but only fetching the age attribute + Account.getItems(['test5@example.com', 'test6@example.com'], {AttributesToGet : ['age']}, function (err, accounts) { + accounts.forEach(function (acc) { + printAccountInfo(null, acc); + }); }); }); diff --git a/examples/binary.js b/examples/binary.js index 56d285b..fecf441 100644 --- a/examples/binary.js +++ b/examples/binary.js @@ -2,15 +2,18 @@ var vogels = require('../index'), fs = require('fs'), - AWS = vogels.AWS; + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var File = vogels.define('File', function (schema) { - schema.String('name', {hashKey: true}); - schema.Binary('data'); - - schema.Date('created', {default: Date.now}); +var BinModel = vogels.define('example-binary', { + hashKey : 'name', + timestamps : true, + schema : { + name : Joi.string(), + data : Joi.binary() + } }); var printFileInfo = function (err, file) { @@ -23,10 +26,18 @@ var printFileInfo = function (err, file) { } }; -fs.readFile(__dirname + '/basic.js', function (err, data) { - if (err) { - throw err; +vogels.createTables(function (err) { + if(err) { + console.log('Error creating tables', err); + process.exit(1); } - File.create({name : 'basic.js', data: data}, printFileInfo); + fs.readFile(__dirname + '/basic.js', function (err, data) { + if (err) { + throw err; + } + + BinModel.create({name : 'basic.js', data: data}, printFileInfo); + + }); }); diff --git a/examples/createTable.js b/examples/createTable.js index 3373288..6322b71 100644 --- a/examples/createTable.js +++ b/examples/createTable.js @@ -1,45 +1,44 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -vogels.define('Account', function (schema) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age', {secondaryIndex: true}); - schema.Date('created', {default: Date.now}); +vogels.define('example-Account', { + hashKey : 'name', + rangeKey : 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + }, + indexes : [ + {hashKey : 'name', rangeKey : 'age', type: 'local', name : 'NameAgeIndex'}, + ] }); -vogels.define('GameScore', function (schema) { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); - schema.Date('topScoreDateTime'); - schema.Number('wins'); - schema.Number('losses'); - - schema.globalIndex('GameTitleIndex', { - hashKey: 'gameTitle', - rangeKey: 'topScore', - Projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } //optional, defaults to ALL - }); +vogels.define('example-GameScore', { + hashKey : 'userId', + rangeKey : 'gameTitle', + schema : { + userId : Joi.string(), + gameTitle : Joi.string(), + topScore : Joi.number(), + topScoreDateTime : Joi.date(), + wins : Joi.number(), + losses : Joi.number() + }, + indexes : [{ + hashKey : 'gameTitle', + rangeKey : 'topScore', + type : 'global', + name : 'GameTitleIndex', + projection : { NonKeyAttributes : [ 'wins' ], ProjectionType : 'INCLUDE' } + }] }); -//Account.createTable(function (err, result) { - //if(err) { - //console.log('error create table', err); - //} else { - - //console.log(err, result); - - //Account.describeTable(function (err, result) { - //console.log('table info', result); - //}); - //} -//}); - vogels.createTables({ 'Account' : {readCapacity: 1, writeCapacity: 1}, 'GameScore' : {readCapacity: 1, writeCapacity: 1} diff --git a/examples/dynamicKeys.js b/examples/dynamicKeys.js new file mode 100644 index 0000000..14db513 --- /dev/null +++ b/examples/dynamicKeys.js @@ -0,0 +1,63 @@ +'use strict'; + +var vogels = require('../index'), + AWS = vogels.AWS, + Joi = require('joi'), + async = require('async'), + util = require('util'), + _ = require('lodash'); + +AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); + +var DynamicModel = vogels.define('example-dynamic-key', { + hashKey : 'id', + timestamps : true, + schema : Joi.object().keys({ + id : Joi.string() + }).unknown() +}); + +var printResults = function (err, resp) { + console.log('----------------------------------------------------------------------'); + if(err) { + console.log('Error running scan', err); + } else { + console.log('Found', resp.Count, 'items'); + console.log(util.inspect(_.pluck(resp.Items, 'attrs'))); + + if(resp.ConsumedCapacity) { + console.log('----------------------------------------------------------------------'); + console.log('Scan consumed: ', resp.ConsumedCapacity); + } + } + + console.log('----------------------------------------------------------------------'); +}; + +vogels.createTables({ + 'example-Account' : {readCapacity: 1, writeCapacity: 10}, +}, function (err) { + if(err) { + console.log('Error creating tables', err); + process.exit(1); + } + + async.times(25, function(n, next) { + var data = {id : 'Model ' + n}; + + if(n % 3 === 0) { + data.name = 'Dynamic Model the 3rd'; + data.age = 33; + } + + if(n % 5 === 0) { + data.email = 'model_' + n + '@test.com'; + data.settings = { nickname : 'Model the 5th' }; + } + + DynamicModel.create(data, next); + }, function () { + + DynamicModel.scan().loadAll().exec(printResults); + }); +}); diff --git a/examples/dynamicTableName.js b/examples/dynamicTableName.js index 04bfc96..5c56a00 100644 --- a/examples/dynamicTableName.js +++ b/examples/dynamicTableName.js @@ -1,20 +1,23 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - schema.Date('created', {default: Date.now}); - - schema.tableName = function () { +var Account = vogels.define('example-tablename', { + hashKey : 'email', + timestamps : true, + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + }, + tableName : function () { var d = new Date(); - return ['accounts', d.getFullYear(), d.getMonth() + 1].join('_'); - }; + return ['example-dynamic-tablename', d.getFullYear(), d.getMonth() + 1].join('_'); + } }); var printAccountInfo = function (err, acc) { diff --git a/examples/globalSecondaryIndexes.js b/examples/globalSecondaryIndexes.js index 52f7b6c..1baa006 100644 --- a/examples/globalSecondaryIndexes.js +++ b/examples/globalSecondaryIndexes.js @@ -1,49 +1,36 @@ 'use strict'; var vogels = require('../index'), - _ = require('lodash'), - AWS = vogels.AWS; + _ = require('lodash'), + util = require('util'), + AWS = vogels.AWS, + Joi = require('joi'), + async = require('async'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); // http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html -var GameScore = vogels.define('GameScore', function (schema) { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); - schema.Date('topScoreDateTime'); - schema.Number('wins'); - schema.Number('losses'); - - schema.globalIndex('GameTitleIndex', { - hashKey: 'gameTitle', - rangeKey: 'topScore', - Projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } //optional, defaults to ALL - }); +var GameScore = vogels.define('example-global-index', { + hashKey : 'userId', + rangeKey : 'gameTitle', + schema : { + userId : Joi.string(), + gameTitle : Joi.string(), + topScore : Joi.number(), + topScoreDateTime : Joi.date(), + wins : Joi.number(), + losses : Joi.number() + }, + indexes : [{ + hashKey : 'gameTitle', + rangeKey : 'topScore', + name : 'GameTitleIndex', + type : 'global', + projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } + }] }); -//GameScore.createTable(function (err, result) { - //if(err) { - //console.log('error creating table', err); - //} else { - - //console.log(err, result); - - //GameScore.describeTable(function (err, result) { - //console.log('table info', result); - //}); - //} -//}); - -var createGameScoreRecord = function (attrs) { - GameScore.create(attrs, function (err) { - if(err) { - console.log('error creating game score record', err); - } - }); -}; - var data = [ {userId: '101', gameTitle : 'Galaxy Invaders', topScore: 5842, wins: 10, losses: 5 , topScoreDateTime: new Date(2012, 1, 3, 8, 30)}, {userId: '101', gameTitle : 'Meteor Blasters', topScore: 1000, wins: 12, losses: 3, topScoreDateTime: new Date(2013, 1, 3, 8, 30) }, @@ -58,10 +45,25 @@ var data = [ {userId: '103', gameTitle : 'Starship X', topScore: 42, wins: 4, losses: 19 }, ]; -_.each(data, createGameScoreRecord); +var loadSeedData = function (callback) { + callback = callback || _.noop; + + async.each(data, function(attrs, callback) { + GameScore.create(attrs,callback); + }, callback); +}; -// Perform query against global secondary index -GameScore +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { + if(err) { + console.log('error', err); + process.exit(1); + } + + // Perform query against global secondary index + GameScore .query('Galaxy Invaders') .usingIndex('GameTitleIndex') .where('topScore').gt(0) @@ -70,7 +72,8 @@ GameScore if(err){ console.log(err); } else { - console.log(_.map(data.Items, JSON.stringify)); + console.log('Found', data.Count, 'items'); + console.log(util.inspect(_.pluck(data.Items, 'attrs'))); } }); - +}); diff --git a/examples/hooks.js b/examples/hooks.js index 33f57fe..3ffff88 100644 --- a/examples/hooks.js +++ b/examples/hooks.js @@ -1,16 +1,19 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; - -AWS.config.loadFromPath(process.env.HOME + '/.ec2/vogels.json'); - -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - schema.Date('created', {default: Date.now}); - schema.Date('updated'); + AWS = vogels.AWS, + Joi = require('joi'); + +AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); + +var Account = vogels.define('example-hook', { + hashKey : 'email', + timestamps : true, + schema : { + email : Joi.string().email(), + name : Joi.string(), + age : Joi.number(), + } }); Account.before('create', function (data, next) { @@ -22,12 +25,8 @@ Account.before('create', function (data, next) { }); Account.before('update', function (data, next) { - - setTimeout(function () { - data.updated = Date.now(); - return next(null, data); - }, 1000); - + data.age = 45; + return next(null, data); }); Account.after('create', function (item) { @@ -42,12 +41,19 @@ Account.after('destroy', function (item) { console.log('Account destroyed', item.get()); }); -Account.create({email: 'test11@example.com'}, function (err, acc) { - acc.set({age: 25}); +vogels.createTables(function (err) { + if(err) { + console.log('Error creating tables', err); + process.exit(1); + } + + Account.create({email: 'test11@example.com'}, function (err, acc) { + acc.set({age: 25}); + + acc.update(function () { + acc.destroy({ReturnValues: 'ALL_OLD'}); + }); - acc.update(function () { - acc.destroy({ReturnValues: 'ALL_OLD'}); }); }); - diff --git a/examples/modelMethods.js b/examples/modelMethods.js index f2a289e..7195e0b 100644 --- a/examples/modelMethods.js +++ b/examples/modelMethods.js @@ -1,15 +1,19 @@ 'use strict'; var vogels = require('../index'), + Joi = require('joi'), AWS = vogels.AWS; AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - schema.Date('created', {default: Date.now}); +var Account = vogels.define('example-model-methods-Account', { + hashKey : 'email', + timestamps : true, + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } }); Account.prototype.sayHello = function () { diff --git a/examples/nestedAttributes.js b/examples/nestedAttributes.js new file mode 100644 index 0000000..4dbac2c --- /dev/null +++ b/examples/nestedAttributes.js @@ -0,0 +1,112 @@ +'use strict'; + +var vogels = require('../index'), + util = require('util'), + _ = require('lodash'), + async = require('async'), + Joi = require('joi'), + AWS = vogels.AWS; + +AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); + +var Movie = vogels.define('example-nested-attribute', { + hashKey : 'title', + timestamps : true, + schema : { + title : Joi.string(), + releaseYear : Joi.number(), + tags : vogels.types.stringSet(), + director : Joi.object().keys({ + firstName : Joi.string(), + lastName : Joi.string(), + titles : Joi.array() + }), + actors : Joi.array().includes(Joi.object().keys({ + firstName : Joi.string(), + lastName : Joi.string(), + titles : Joi.array() + })) + } +}); + +var printResults = function (err, data) { + console.log('----------------------------------------------------------------------'); + if(err) { + console.log('Error - ', err); + } else { + console.log('Movie - ', util.inspect(data.get(), {depth : null})); + } + console.log('----------------------------------------------------------------------'); +}; + +var loadSeedData = function (callback) { + callback = callback || _.noop; + + async.times(10, function(n, next) { + var director = { firstName : 'Steven', lastName : 'Spielberg the ' + n, titles : ['Producer', 'Writer', 'Director']}; + var actors = [ + { firstName : 'Tom', lastName : 'Hanks', titles : ['Producer', 'Actor', 'Soundtrack']} + ]; + + var tags = ['tag ' + n]; + + if(n %3 === 0) { + actors.push({ firstName : 'Rex', lastName : 'Ryan', titles : ['Actor', 'Head Coach']}); + tags.push('Action'); + } + + if(n %5 === 0) { + actors.push({ firstName : 'Tom', lastName : 'Coughlin', titles : ['Writer', 'Head Coach']}); + tags.push('Comedy'); + } + + Movie.create({title : 'Movie ' + n, releaseYear : 2001 + n, actors : actors, director : director, tags: tags}, next); + }, callback); +}; + +var runExample = function () { + + Movie.create({ + title : 'Star Wars: Episode IV - A New Hope', + releaseYear : 1977, + director : { + firstName : 'George', lastName : 'Lucas', titles : ['Director'] + }, + actors : [ + { firstName : 'Mark', lastName : 'Hamill', titles : ['Actor']}, + { firstName : 'Harrison', lastName : 'Ford', titles : ['Actor', 'Producer']}, + { firstName : 'Carrie', lastName : 'Fisher', titles : ['Actress', 'Writer']}, + ], + tags : ['Action', 'Adventure'] + }, printResults); + + var params = {}; + params.UpdateExpression = 'SET #year = #year + :inc, #dir.titles = list_append(#dir.titles, :title), #act[0].firstName = :firstName ADD tags :tag'; + params.ConditionExpression = '#year = :current'; + params.ExpressionAttributeNames = { + '#year' : 'releaseYear', + '#dir' : 'director', + '#act' : 'actors' + }; + params.ExpressionAttributeValues = { + ':inc' : 1, + ':current' : 2001, + ':title' : ['The Man'], + ':firstName' : 'Rob', + ':tag' : vogels.Set(['Sports', 'Horror'], 'S') + }; + + Movie.update({title : 'Movie 0'}, params, printResults); +}; + +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { + if(err) { + console.log('error', err); + process.exit(1); + } + + runExample(); +}); diff --git a/examples/optionalAttributes.js b/examples/optionalAttributes.js index 4a83508..467b990 100644 --- a/examples/optionalAttributes.js +++ b/examples/optionalAttributes.js @@ -1,13 +1,17 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Person = vogels.define('Person', function (schema) { - schema.UUID('id', {hashKey: true}); - schema.String('name').allow(null); +var Person = vogels.define('example-optional-attribute', { + hashKey : 'id', + schema : { + id : vogels.types.uuid(), + name : Joi.string().allow(null) + } }); var printInfo = function (err, person) { @@ -22,7 +26,8 @@ var printInfo = function (err, person) { vogels.createTables( function (err) { if(err) { - return console.log('Failed to create table', err); + console.log('Failed to create table', err); + process.exit(1); } Person.create({name : 'Nick'}, printInfo); diff --git a/examples/parallelscan.js b/examples/parallelscan.js index 5997b0e..db65517 100644 --- a/examples/parallelscan.js +++ b/examples/parallelscan.js @@ -1,16 +1,22 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + AWS = vogels.AWS, + _ = require('lodash'), + Joi = require('joi'), + async = require('async'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Product = vogels.define('Product', function (schema) { - schema.String('id', {hashKey: true}); - schema.Number('accountID'); - schema.String('purchased'); - schema.Date('ctime'); - schema.Number('price'); +var Product = vogels.define('example-parallel-scan', { + hashKey : 'id', + timestamps : true, + schema : { + id : vogels.types.uuid(), + accountId : Joi.number(), + purchased : Joi.boolean().default(false), + price : Joi.number() + }, }); var printInfo = function (err, resp) { @@ -30,9 +36,34 @@ var printInfo = function (err, resp) { console.log('Average purchased price', totalPrices / resp.Count); }; -var totalSegments = 8; +var loadSeedData = function (callback) { + callback = callback || _.noop; -Product.parallelScan(totalSegments) - .where('purchased').equals('true') + async.times(30, function(n, next) { + var purchased = n %4 === 0 ? true : false; + Product.create({accountId : n %5, purchased : purchased, price : n}, next); + }, callback); +}; + +var runParallelScan = function () { + + var totalSegments = 8; + + Product.parallelScan(totalSegments) + .where('purchased').equals(true) .attributes('price') .exec(printInfo); +}; + +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { + if(err) { + console.log('error', err); + process.exit(1); + } + + runParallelScan(); +}); + diff --git a/examples/query.js b/examples/query.js index 2a0a7b1..416ccc4 100644 --- a/examples/query.js +++ b/examples/query.js @@ -1,17 +1,27 @@ 'use strict'; var vogels = require('../index'), - util = require('util'), - _ = require('lodash'), + util = require('util'), + _ = require('lodash'), + async = require('async'), + Joi = require('joi'), AWS = vogels.AWS; AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - schema.Number('age'); +var Account = vogels.define('example-query', { + hashKey : 'name', + rangeKey : 'email', + timestamps : true, + schema : { + name : Joi.string(), + email : Joi.string().email(), + age : Joi.number(), + }, + + indexes : [ + {hashKey : 'name', rangeKey : 'createdAt', type : 'local', name : 'CreatedAtIndex'} + ] }); var printResults = function (err, resp) { @@ -31,28 +41,53 @@ var printResults = function (err, resp) { console.log('----------------------------------------------------------------------'); }; -// Basic query against hash key -Account.query('Test').exec(printResults); +var loadSeedData = function (callback) { + callback = callback || _.noop; + + async.times(25, function(n, next) { + var prefix = n %5 === 0 ? 'foo' : 'test'; + Account.create({email: prefix + n + '@example.com', name : 'Test ' + n %3, age: n}, next); + }, callback); +}; + +var runQueries = function () { + // Basic query against hash key + Account.query('Test 0').exec(printResults); -// Run query limiting returned items to 3 -Account.query('Test').limit(3).exec(printResults); + // Run query limiting returned items to 3 + Account.query('Test 0').limit(3).exec(printResults); -// Query with rang key condition -Account.query('Test') + // Query with rang key condition + Account.query('Test 1') .where('email').beginsWith('foo') .exec(printResults); -// Run query returning only email and created attributes -// also returns consumed capacity query took -Account.query('Test') + // Run query returning only email and created attributes + // also returns consumed capacity query took + Account.query('Test 2') .where('email').gte('a@example.com') - .attributes(['email','created']) + .attributes(['email','createdAt']) .returnConsumedCapacity() .exec(printResults); -// Run query against secondary index -Account.query('Test') - .usingIndex('createdIndex') - .where('created').lt(Date.now()) + // Run query against secondary index + Account.query('Test 0') + .usingIndex('CreatedAtIndex') + .where('createdAt').lt(new Date().toISOString()) .descending() .exec(printResults); + + +}; + +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { + if(err) { + console.log('error', err); + process.exit(1); + } + + runQueries(); +}); diff --git a/examples/queryFilter.js b/examples/queryFilter.js index e0ca3cc..03e23ac 100644 --- a/examples/queryFilter.js +++ b/examples/queryFilter.js @@ -1,18 +1,28 @@ 'use strict'; var vogels = require('../index'), - util = require('util'), - _ = require('lodash'), + util = require('util'), + _ = require('lodash'), + Joi = require('joi'), + async = require('async'), AWS = vogels.AWS; AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('ExampleAccount', function (schema) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - schema.Number('age'); - schema.StringSet('roles'); +var Account = vogels.define('example-query-filter', { + hashKey : 'name', + rangeKey : 'email', + timestamps : true, + schema : { + name : Joi.string(), + email : Joi.string().email(), + age : Joi.number(), + roles : vogels.types.stringSet(), + }, + + indexes : [ + {hashKey : 'name', rangeKey : 'createdAt', type : 'local', name : 'CreatedAtIndex'} + ] }); var printResults = function (msg) { @@ -20,7 +30,7 @@ var printResults = function (msg) { console.log('----------------------------------------------------------------------'); if(err) { - console.log('Error running query', err); + console.log(msg + ' - Error running query', err); } else { console.log(msg + ' - Found', resp.Count, 'items'); console.log(util.inspect(_.pluck(resp.Items, 'attrs'))); @@ -35,11 +45,13 @@ var printResults = function (msg) { }; }; -var loadSeedData = function () { - _.times(30, function(n) { +var loadSeedData = function (callback) { + callback = callback || _.noop; + + async.times(30, function(n, next) { var roles = n %3 === 0 ? ['admin', 'editor'] : ['user']; - Account.create({email: 'test' + n + '@example.com', name : 'Test ' + n %3, age: n, roles : roles}, _.noop); - }); + Account.create({email: 'test' + n + '@example.com', name : 'Test ' + n %3, age: n, roles : roles}, next); + }, callback); }; var runFilterQueries = function () { @@ -49,7 +61,7 @@ var runFilterQueries = function () { // between filter - Account.query('Test 1').filter('age').between([5, 10]).exec(printResults('Between Filter')); + Account.query('Test 1').filter('age').between(5, 10).exec(printResults('Between Filter')); // IN filter Account.query('Test 1').filter('age').in([5, 10]).exec(printResults('IN Filter')); @@ -65,13 +77,14 @@ var runFilterQueries = function () { Account.query('Test 1').filter('roles').notContains('admin').exec(printResults('NOT contains admin Filter')); }; -vogels.createTables(function (err) { +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { if(err) { - console.log('Error creating tables', err); - process.exit(); - } else { - console.log('table are now created and active'); - loadSeedData(); - runFilterQueries(); + console.log('error', err); + process.exit(1); } + + runFilterQueries(); }); diff --git a/examples/scan.js b/examples/scan.js index 81abb9d..b0c36ec 100644 --- a/examples/scan.js +++ b/examples/scan.js @@ -1,18 +1,24 @@ 'use strict'; var vogels = require('../index'), - util = require('util'), - _ = require('lodash'), - AWS = vogels.AWS; + util = require('util'), + _ = require('lodash'), + AWS = vogels.AWS, + async = require('async'), + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - schema.Number('age'); - schema.NumberSet('scores'); +var Account = vogels.define('example-scan', { + hashKey : 'name', + rangeKey : 'email', + timestamps : true, + schema : { + name : Joi.string(), + email : Joi.string().email(), + age : Joi.number(), + scores : vogels.types.numberSet(), + }, }); var printResults = function (err, resp) { @@ -32,25 +38,50 @@ var printResults = function (err, resp) { console.log('----------------------------------------------------------------------'); }; -// Basic scan against table -Account.scan().exec(printResults); +var loadSeedData = function (callback) { + callback = callback || _.noop; -// Run scan limiting returned items to 4 -Account.scan().limit(4).exec(printResults); + async.times(30, function(n, next) { + var scores = n %5 === 0 ? [3, 4, 5] : [1,2]; + Account.create({email: 'test' + n + '@example.com', name : 'Test ' + n %3, age: n, scores : scores}, next); + }, callback); +}; + + +var runScans = function () { -// Scan with key condition -Account.scan() + // Basic scan against table + Account.scan().exec(printResults); + + // Run scan limiting returned items to 4 + Account.scan().limit(4).exec(printResults); + + // Scan with key condition + Account.scan() .where('email').beginsWith('test5') .exec(printResults); -// Run scan returning only email and created attributes -// also returns consumed capacity the scan took -Account.scan() + // Run scan returning only email and created attributes + // also returns consumed capacity the scan took + Account.scan() .where('email').gte('f@example.com') - .attributes(['email','created']) + .attributes(['email','createdAt']) .returnConsumedCapacity() .exec(printResults); -Account.scan() + Account.scan() .where('scores').contains(2) .exec(printResults); +}; + +async.series([ + async.apply(vogels.createTables.bind(vogels)), + loadSeedData +], function (err) { + if(err) { + console.log('error', err); + process.exit(1); + } + + runScans(); +}); diff --git a/examples/schema.js b/examples/schema.js deleted file mode 100644 index 98149a4..0000000 --- a/examples/schema.js +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -var vogels = require('vogels'); - -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('username').alphanum().min(3).max(30).required(); - schema.String('name').required(); - schema.Number('birthYear').min(1850).max(2012); -}); - -var BlogPost = vogels.define('BlogPost', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('title', {rangeKey: true}); - schema.Date('published', {secondaryIndex: true}); - schema.String('content').required(); -}); - -var acc = new Account({ - email : 'vogels@test.com', - username : 'werner', - name : 'Werner Vogels', - birthYear: 1958 -}); - -var publishedPost = new BlogPost({ - email : acc.email, - title : 'Expanding the Cloud: Faster, More Flexible Queries with DynamoDB', - content : 'Today, I’m thrilled to announce that we have expanded the query capabilities of DynamoDB.', - published : new Date('2013-04-17 10:30:00') -}); - - -var unpublishedPost = new BlogPost({ - email : acc.email, - title : 'Expanding the Cloud: Spaceships!', - content : 'Today, I’m thrilled to announce...' -}); - -acc.save(function (err){ - if(err) {console.log('error saving account', err);} - - // account created! - //save both posts - - unpublishedPost.save(); - publishedPost.save(); -}); - -Account.get('vogels@test.com', function (err, account) { - if(err) {console.log('error getting account', err);} - - // got account - console.log('Found account', account); -}); - -BlogPost.query('vogels@test.com').exec(console.log); // Get all blog posts created by vogels -BlogPost.query('vogels@test.com').usingIndex('published').exec(console.log); // Get all published blogposts by vogels diff --git a/examples/streaming.js b/examples/streaming.js index 7d54542..697bfc1 100644 --- a/examples/streaming.js +++ b/examples/streaming.js @@ -1,17 +1,20 @@ 'use strict'; var vogels = require('../index'), + Joi = require('joi'), AWS = vogels.AWS; AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Product = vogels.define('Product', function (schema) { - schema.String('ProductId', {hashKey: true}); - schema.String('host'); - schema.String('url'); - schema.String('title'); - - schema.Date('created'); +var Product = vogels.define('example-streaming-Product', { + hashKey : 'ProductId', + timestamps : true, + schema : { + ProductId : Joi.string(), + host : Joi.string(), + url : Joi.string(), + title : Joi.string() + } }); var printStream = function (msg, stream) { diff --git a/examples/update.js b/examples/update.js index 058b437..73f8272 100644 --- a/examples/update.js +++ b/examples/update.js @@ -1,29 +1,47 @@ 'use strict'; var vogels = require('../index'), - AWS = vogels.AWS; + AWS = vogels.AWS, + Joi = require('joi'); AWS.config.loadFromPath(process.env.HOME + '/.ec2/credentials.json'); -var Account = vogels.define('Account', function (schema) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - schema.StringSet('nicknames'); +var Account = vogels.define('example-update', { + hashKey : 'email', + timestamps : true, + schema : { + email : Joi.string().email(), + name : Joi.string(), + age : Joi.number(), + nicknames : vogels.types.stringSet(), + nested : Joi.object() + } }); -Account.update({email : 'test5@example.com', age : {$add : 1}}, function (err, acc) { - console.log('incremented age', acc.get('age')); -}); +vogels.createTables(function (err) { + if(err) { + console.log('Error creating tables', err); + process.exit(1); + } -Account.update({email : 'test@example.com', nicknames : {$add : 'smalls'}}, function (err, acc) { - console.log('added one nickname', acc.get('nicknames')); -}); + Account.update({email : 'test5@example.com', age : {$add : 1}}, function (err, acc) { + console.log('incremented age', acc.get('age')); + }); -Account.update({email : 'test@example.com', nicknames : {$add : ['bigs', 'big husk', 'the dude']}}, function (err, acc) { - console.log('added three nicknames', acc.get('nicknames')); -}); + Account.update({email : 'test@example.com', nicknames : {$add : 'smalls'}}, function (err, acc) { + console.log('added one nickname', acc.get('nicknames')); + }); + + Account.update({email : 'test@example.com', nicknames : {$add : ['bigs', 'big husk', 'the dude']}}, function (err, acc) { + console.log('added three nicknames', acc.get('nicknames')); + }); + + Account.update({email : 'test@example.com', nicknames : {$del : 'the dude'}}, function (err, acc) { + console.log('removed nickname', acc.get('nicknames')); + }); + + Account.update({email : 'test@example.com', nested : {roles : ['guest']}}, function (err, acc) { + console.log('added nested data', acc.get('nested')); + }); -Account.update({email : 'test@example.com', nicknames : {$del : 'the dude'}}, function (err, acc) { - console.log('removed nickname', acc.get('nicknames')); }); diff --git a/fig.yml b/fig.yml new file mode 100644 index 0000000..7a260d0 --- /dev/null +++ b/fig.yml @@ -0,0 +1,15 @@ + +lib: + build: . + command: make + volumes: + - .:/app + links: + - db:dynamodb-local + environment: + AWS_ACCESS_KEY_ID: AKID + AWS_SECRET_ACCESS_KEY: SECRET + AWS_REGION: us-east-1 +db: + image: fitz/dynamodb-local + command: -inMemory diff --git a/lib/batch.js b/lib/batch.js index 534eeda..68c16ab 100644 --- a/lib/batch.js +++ b/lib/batch.js @@ -6,21 +6,9 @@ var _ = require('lodash'), var internals = {}; internals.buildInitialGetItemsRequest = function (tableName, keys, options) { - options = options || {}; - var request = {}; - request[tableName] = { - Keys : keys - }; - - if(options.ConsistentRead === true) { - request[tableName].ConsistentRead = true; - } - - if(options.AttributesToGet) { - request[tableName].AttributesToGet = options.AttributesToGet; - } + request[tableName] = _.merge({}, {Keys : keys}, options); return { RequestItems : request }; }; @@ -105,7 +93,7 @@ internals.initialBatchGetItems = function (keys, table, serializer, options, cal var dynamoItems = data.Responses[table.tableName()]; var items = _.map(dynamoItems, function(i) { - return table.initItem(serializer.deserializeItem(table.schema, i)); + return table.initItem(serializer.deserializeItem(i)); }); return callback(null, items); diff --git a/lib/createTables.js b/lib/createTables.js index 40ec8f0..674edf8 100644 --- a/lib/createTables.js +++ b/lib/createTables.js @@ -13,7 +13,13 @@ internals.createTable = function (model, options, callback) { model.describeTable(function (err, data) { if(_.isNull(data)) { console.log('creating table', tableName); - return model.createTable(options, function () { + return model.createTable(options, function (error) { + + if(error) { + console.error('failed to created table ' + tableName, error); + return callback(error); + } + console.log('waiting for table ' + tableName + ' to become ACTIVE'); internals.waitTillActive(model, callback); }); diff --git a/lib/expressions.js b/lib/expressions.js new file mode 100644 index 0000000..df72b53 --- /dev/null +++ b/lib/expressions.js @@ -0,0 +1,93 @@ +'use strict'; + +var _ = require('lodash'), + utils = require('./utils'), + serializer = require('./serializer'); + +var internals = {}; + +internals.actionWords = ['SET', 'ADD', 'REMOVE', 'DELETE']; + +internals.regexMap = _.reduce(internals.actionWords, function (result, key) { + result[key] = new RegExp(key + '\\s*(.+?)\\s*(SET|ADD|REMOVE|DELETE|$)'); + return result; +}, {}); + +// explanation http://stackoverflow.com/questions/3428618/regex-to-find-commas-that-arent-inside-and +internals.splitOperandsRegex = new RegExp(/\s*(?![^(]*\)),\s*/); + +internals.match = function (actionWord, str) { + var match = internals.regexMap[actionWord].exec(str); + + if(match && match.length >= 2) { + return match[1].split(internals.splitOperandsRegex); + } else { + return null; + } +}; + +exports.parse = function (str) { + return _.reduce(internals.actionWords, function (result, actionWord) { + result[actionWord] = internals.match(actionWord, str); + return result; + }, {}); +}; + +exports.serializeUpdateExpression = function (schema, item) { + var datatypes = schema._modelDatatypes; + + var data = utils.omitPrimaryKeys(schema, item); + + var memo = { + expressions : {}, + attributeNames : {}, + values : {}, + }; + + memo.expressions = _.reduce(internals.actionWords, function (result, key) { + result[key] = []; + + return result; + }, {}); + + var result = _.reduce(data, function (result, value, key) { + var valueKey = ':' + key; + var nameKey = '#' + key; + + if(_.isNull(value)) { + result.expressions.REMOVE.push(nameKey); + result.attributeNames[nameKey] = key; + } else if (_.isPlainObject(value) && value.$add) { + result.expressions.ADD.push(nameKey + ' ' + valueKey); + result.values[valueKey] = serializer.serializeAttribute(value.$add, datatypes[key]); + result.attributeNames[nameKey] = key; + } else if (_.isPlainObject(value) && value.$del) { + result.expressions.DELETE.push(nameKey + ' ' + valueKey); + result.values[valueKey] = serializer.serializeAttribute(value.$del, datatypes[key]); + result.attributeNames[nameKey] = key; + } else { + result.expressions.SET.push(nameKey + ' = ' + valueKey); + result.values[valueKey] = serializer.serializeAttribute(value, datatypes[key]); + result.attributeNames[nameKey] = key; + } + + return result; + }, memo); + + return result; +}; + +exports.stringify = function (expressions) { + return _.reduce(expressions, function (result, value, key) { + if(!_.isEmpty(value)) { + if(_.isArray(value)) { + result.push(key + ' ' + value.join(', ')); + } else { + result.push(key + ' ' + value); + } + } + + return result; + }, []).join(' '); +}; + diff --git a/lib/index.js b/lib/index.js index 02237a9..b0fba56 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ var _ = require('lodash'), util = require('util'), AWS = require('aws-sdk'), + DOC = require('dynamodb-doc'), Table = require('./table'), Schema = require('./schema'), serializer = require('./serializer'), @@ -19,6 +20,7 @@ var internals = {}; vogels.dynamoDriver = internals.dynamoDriver = function (driver) { if(driver) { internals.dynamodb = driver; + internals.loadDocClient(driver); internals.updateDynamoDBDriverForAllModels(driver); } else { internals.dynamodb = internals.dynamodb || new vogels.AWS.DynamoDB({apiVersion: '2012-08-10'}); @@ -33,12 +35,22 @@ internals.updateDynamoDBDriverForAllModels = function (driver) { }); }; +internals.loadDocClient = function (driver) { + if(driver) { + internals.docClient = new DOC.DynamoDB(driver); + } else { + internals.docClient = internals.docClient || new DOC.DynamoDB(internals.dynamoDriver()); + } + + return internals.docClient; +}; + internals.compileModel = function (name, schema) { // extremly simple table names var tableName = name.toLowerCase() + 's'; - var table = new Table(tableName, schema, serializer, internals.dynamoDriver()); + var table = new Table(tableName, schema, serializer, internals.loadDocClient()); var Model = function (attrs) { Item.call(this, attrs, table); @@ -61,6 +73,7 @@ internals.compileModel = function (name, schema) { Model.createTable = _.bind(table.createTable, table); Model.updateTable = _.bind(table.updateTable, table); Model.describeTable = _.bind(table.describeTable, table); + Model.deleteTable = _.bind(table.deleteTable, table); Model.tableName = _.bind(table.tableName, table); table.itemFactory = Model; @@ -70,8 +83,8 @@ internals.compileModel = function (name, schema) { Model.before = _.bind(table.before, table); /* jshint camelcase:false */ - Model.__defineGetter__('dynamodb', function(){ - return table.dynamodb; + Model.__defineGetter__('docClient', function(){ + return table.docClient; }); Model.config = function(config) { @@ -82,7 +95,7 @@ internals.compileModel = function (name, schema) { } if(config.dynamodb) { - table.dynamodb = config.dynamodb; + table.docClient = new DOC.DynamoDB(config.dynamodb); } return table.config; @@ -101,15 +114,19 @@ vogels.reset = function () { vogels.models = {}; }; -vogels.define = function (modelName, callback) { - var schema = new Schema(); - - var compiledTable = internals.compileModel(modelName, schema); +vogels.Set = function () { + return internals.docClient.Set.apply(internals.docClient, arguments); +}; - if(callback) { - callback(schema); +vogels.define = function (modelName, config) { + if(_.isFunction(config)) { + throw new Error('define no longer accepts schema callback, migrate to new api'); } + var schema = new Schema(config); + + var compiledTable = internals.compileModel(modelName, schema); + return compiledTable; }; @@ -127,9 +144,12 @@ vogels.createTables = function (options, callback) { options = {}; } - callback = callback || function () {}; + callback = callback || _.noop; + options = options || {}; return createTables(vogels.models, options, callback); }; +vogels.types = Schema.types; + vogels.reset(); diff --git a/lib/item.js b/lib/item.js index 16a24dc..e6f8380 100644 --- a/lib/item.js +++ b/lib/item.js @@ -63,7 +63,9 @@ Item.prototype.update = function (options, callback) { return callback(err); } - self.set(item.attrs); + if(item) { + self.set(item.attrs); + } return callback(null, item); }); diff --git a/lib/parallelScan.js b/lib/parallelScan.js index 4a6e584..9cb7cd2 100644 --- a/lib/parallelScan.js +++ b/lib/parallelScan.js @@ -1,11 +1,11 @@ 'use strict'; var Scan = require('./scan'), -async = require('async'), -NodeUtil = require('util'), -utils = require('./utils'), -Readable = require('readable-stream'), -_ = require('lodash'); + async = require('async'), + NodeUtil = require('util'), + utils = require('./utils'), + Readable = require('stream').Readable, + _ = require('lodash'); var ParallelScan = module.exports = function (table, serializer, totalSegments) { Scan.call(this, table, serializer); @@ -21,9 +21,6 @@ ParallelScan.prototype.exec = function (callback) { var streamMode = false; var combinedStream = new Readable({objectMode: true}); - combinedStream._read = function () { - }; - if(!callback) { streamMode = true; callback = function (err) { @@ -44,6 +41,8 @@ ParallelScan.prototype.exec = function (callback) { if(streamMode) { var stream = scn.exec(); + stream.on('error', callback); + stream.on('readable', function () { var data = stream.read(); if(data) { @@ -61,14 +60,29 @@ ParallelScan.prototype.exec = function (callback) { scanFuncs.push(scanFunc); }); - async.parallel(scanFuncs, function (err, responses) { - if(err) { - return callback(err); + var started = false; + var startScans = function () { + if(started) { + return; } - combinedStream.push(null); - return callback(null, utils.mergeResults(responses, self.table.tableName())); - }); + started = true; + + async.parallel(scanFuncs, function (err, responses) { + if(err) { + return callback(err); + } + + combinedStream.push(null); + return callback(null, utils.mergeResults(responses, self.table.tableName())); + }); + }; + + if(streamMode) { + combinedStream._read = startScans; + } else { + startScans(); + } return combinedStream; }; diff --git a/lib/query.js b/lib/query.js index 4cede96..a07ff5b 100644 --- a/lib/query.js +++ b/lib/query.js @@ -8,8 +8,10 @@ var internals = {}; internals.keyCondition = function (keyName, schema, query) { var f = function (operator) { - return function (value) { - var cond = query.buildAttributeValue(keyName, value, operator); + return function (/*values*/) { + var copy = [].slice.call(arguments); + var args = [keyName, operator].concat(copy); + var cond = query.buildAttributeValue.apply(query, args); return query.addKeyCondition(cond); }; }; @@ -34,11 +36,12 @@ internals.queryFilter = function (keyName, schema, query) { operator = 'NULL'; } - var exitsCondition = {}; - exitsCondition[keyName] = {ComparisonOperator: operator}; - return query.addFilterCondition(exitsCondition); + var nullCond = query.table.docClient.Condition(keyName, operator); + return query.addFilterCondition(nullCond); } else { - var cond = query.buildAttributeValue(keyName, value, operator); + var copy = [].slice.call(arguments); + var args = [keyName, operator].concat(copy); + var cond = query.buildAttributeValue.apply(query, args); return query.addFilterCondition(cond); } }; @@ -83,6 +86,30 @@ Query.prototype.limit = function(num) { return this; }; +Query.prototype.filterExpression = function(expression) { + this.request.FilterExpression = expression; + + return this; +}; + +Query.prototype.expressionAttributeValues = function(data) { + this.request.ExpressionAttributeValues = data; + + return this; +}; + +Query.prototype.expressionAttributeNames = function(data) { + this.request.ExpressionAttributeNames = data; + + return this; +}; + +Query.prototype.projectionExpression = function(data) { + this.request.ProjectionExpression = data; + + return this; +}; + Query.prototype.usingIndex = function (name) { this.request.IndexName = name; @@ -101,20 +128,20 @@ Query.prototype.consistentRead = function (read) { Query.prototype.addKeyCondition = function (condition) { if(!this.request.KeyConditions) { - this.request.KeyConditions = {}; + this.request.KeyConditions = []; } - this.request.KeyConditions = _.merge({}, this.request.KeyConditions, condition); + this.request.KeyConditions.push(condition); return this; }; Query.prototype.addFilterCondition = function (condition) { if(!this.request.QueryFilter) { - this.request.QueryFilter = {}; + this.request.QueryFilter = []; } - this.request.QueryFilter = _.merge({}, this.request.QueryFilter, condition); + this.request.QueryFilter.push(condition); return this; }; @@ -194,33 +221,23 @@ Query.prototype.buildKey = function () { key = this.table.schema.globalIndexes[this.request.IndexName].hashKey; } - return this.buildAttributeValue(key, this.hashKey, 'EQ'); + return this.buildAttributeValue(key, 'EQ', this.hashKey); }; -Query.prototype.buildAttributeValue = function (key, value, operator) { - var self = this; - - var result = {}; - - if(!_.isArray(value)) { - value = [value]; +internals.formatAttributeValue = function (val) { + if(_.isDate(val)) { + return val.toISOString(); } - var valueList = _.map(value, function (v) { - var data = {}; - data[key] = v; - - var item = self.serializer.serializeItem(self.table.schema, data, {convertSets: true}); - - return item[key]; - }); + return val; +}; - result[key] = { - AttributeValueList : valueList, - ComparisonOperator : operator - }; +Query.prototype.buildAttributeValue = function (key, operator, val1, val2) { + var self = this; - return result; + var v1 = internals.formatAttributeValue(val1); + var v2 = internals.formatAttributeValue(val2); + return self.table.docClient.Condition(key, operator, v1, v2); }; Query.prototype.buildRequest = function () { diff --git a/lib/scan.js b/lib/scan.js index 347fb05..dd6cf4c 100644 --- a/lib/scan.js +++ b/lib/scan.js @@ -8,8 +8,10 @@ var internals = {}; internals.keyCondition = function (keyName, schema, scan) { var f = function (operator) { - return function (value) { - var cond = scan.buildAttributeValue(keyName, value, operator); + return function (/*values*/) { + var copy = [].slice.call(arguments); + var args = [keyName, operator].concat(copy); + var cond = scan.buildAttributeValue.apply(scan, args); return scan.addKeyCondition(cond); }; }; @@ -52,10 +54,10 @@ Scan.prototype.limit = function(num) { Scan.prototype.addKeyCondition = function (condition) { if(!this.request.ScanFilter) { - this.request.ScanFilter = {}; + this.request.ScanFilter = []; } - this.request.ScanFilter = _.merge({}, this.request.ScanFilter, condition); + this.request.ScanFilter.push(condition) ; return this; }; @@ -104,6 +106,31 @@ Scan.prototype.where = function (keyName) { return internals.keyCondition(keyName, this.table.schema, this); }; + +Scan.prototype.filterExpression = function(expression) { + this.request.FilterExpression = expression; + + return this; +}; + +Scan.prototype.expressionAttributeValues = function(data) { + this.request.ExpressionAttributeValues = data; + + return this; +}; + +Scan.prototype.expressionAttributeNames = function(data) { + this.request.ExpressionAttributeNames = data; + + return this; +}; + +Scan.prototype.projectionExpression = function(data) { + this.request.ProjectionExpression = data; + + return this; +}; + Scan.prototype.exec = function(callback) { var self = this; @@ -120,40 +147,23 @@ Scan.prototype.loadAll = function () { return this; }; -Scan.prototype.buildAttributeValue = function (key, value, operator) { - var self = this; - - var result = {}; - - if(_.isNull(value) || _.isUndefined(value)) { - result[key] = { - ComparisonOperator : operator - }; - - return result; +internals.formatAttributeValue = function (val) { + if(_.isDate(val)) { + return val.toISOString(); } - if(!_.isArray(value)) { - value = [value]; - } - - var valueList = _.map(value, function (v) { - var data = {}; - data[key] = v; - - var item = self.serializer.serializeItem(self.table.schema, data, {convertSets: true}); - - return item[key]; - }); + return val; +}; - result[key] = { - AttributeValueList : valueList, - ComparisonOperator : operator - }; +Scan.prototype.buildAttributeValue = function (key, operator, val1, val2) { + var self = this; - return result; + var v1 = internals.formatAttributeValue(val1); + var v2 = internals.formatAttributeValue(val2); + return self.table.docClient.Condition(key, operator, v1, v2); }; + Scan.prototype.buildRequest = function () { return _.merge({}, this.request, {TableName: this.table.tableName()}); }; diff --git a/lib/schema.js b/lib/schema.js index 76f6cbb..150d7c1 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2,184 +2,166 @@ var Joi = require('joi'), nodeUUID = require('node-uuid'), - binaryType = require('./types/binary'), _ = require('lodash'); var internals = {}; -internals.parseOptions = function (schema, name, options) { - options = options || {}; - - if(options.hashKey) { - schema.hashKey = name; - } else if(options.rangeKey) { - schema.rangeKey = name; - } else if(options.secondaryIndex) { - schema.secondaryIndexes.push(name); +internals.secondaryIndexSchema = Joi.object().keys({ + hashKey : Joi.string().when('type', { is: 'local', then: Joi.ref('$hashKey'), otherwise : Joi.required()}), + rangeKey: Joi.string().when('type', { is: 'local', then: Joi.required(), otherwise: Joi.optional() }), + type : Joi.string().valid('local', 'global').required(), + name : Joi.string().required(), + projection : Joi.object(), + readCapacity : Joi.number().when('type', { is: 'global', then: Joi.optional(), otherwise : Joi.forbidden()}), + writeCapacity : Joi.number().when('type', { is: 'global', then: Joi.optional(), otherwise : Joi.forbidden()}) +}); + +internals.configSchema = Joi.object().keys({ + hashKey : Joi.string().required(), + rangeKey : Joi.string(), + tableName : Joi.alternatives().try(Joi.string(), Joi.func()), + indexes : Joi.array().includes(internals.secondaryIndexSchema), + schema : Joi.object(), + timestamps : Joi.boolean().default(false) +}).required(); + +internals.wireType = function (key) { + switch (key) { + case 'string': + return 'S'; + case 'date': + return 'DATE'; + case 'number': + return 'N'; + case 'boolean': + return 'BOOL'; + case 'binary': + return 'B'; + case 'array': + return 'L'; + default: + return null; } }; -internals.buildSchemaObj = function (attrs) { - return _.reduce(attrs, function (result, val, key) { - result[key] = val.type; - - return result; - }, {}); -}; - -internals.baseSetup = function (schema, attrName, type, attributeType, options) { - internals.parseOptions(schema, attrName, options); +internals.findDynamoTypeMetadata = function (data) { + var meta = _.find(data.meta, function (data) { + return _.isString(data.dynamoType); + }); - schema.attrs[attrName] = { - type: type, - dynamoType : attributeType, - options: options || {} - }; - - if(options && options.hashKey) { - schema.attrs[attrName].type.required(); + if(meta) { + return meta.dynamoType; + } else { + return internals.wireType(data.type); } - - return schema.attrs[attrName].type; -}; - -var Schema = module.exports = function () { - this.attrs = {}; - this.secondaryIndexes = []; - this.globalIndexes = {}; -}; - -Schema.types = {}; - -Schema.types.Binary = binaryType.create; - -Schema.types.StringSet = function () { - var set = Joi.array().includes(Joi.string()); - - set._type = 'stringSet'; - - return set; }; -Schema.types.NumberSet = function () { - var set = Joi.array().includes(Joi.number()); +internals.parseDynamoTypes = function (data) { + if(_.isPlainObject(data) && data.type === 'object' && _.isPlainObject(data.children)) { + return internals.parseDynamoTypes(data.children); + } - set._type = 'numberSet'; - return set; -}; + var mapped = _.reduce(data, function(result, val, key) { + if(val.type === 'object' && _.isPlainObject(val.children)) { + result[key] = internals.parseDynamoTypes(val.children); + } else { + result[key] = internals.findDynamoTypeMetadata(val); + } -Schema.types.BinarySet = function () { - var set = Joi.array().includes(Schema.types.Binary(), Joi.string()); + return result; + }, {}); - set._type = 'binarySet'; - return set; + return mapped; }; -Schema.types.UUID = function () { - var uuidType = Joi.string(); - - uuidType._type = 'uuid'; +var Schema = module.exports = function (config) { + this.secondaryIndexes = {}; + this.globalIndexes = {}; - return uuidType; -}; + var context = {hashKey : config.hashKey}; -Schema.types.TimeUUID = function () { - var uuidType = Joi.string(); + var self = this; + Joi.validate(config, internals.configSchema, { context: context }, function (err, data) { + if(err) { + var msg = 'Invalid table schema, check your config '; + throw new Error(msg + err.annotate()); + } - uuidType._type = 'timeuuid'; + self.hashKey = data.hashKey; + self.rangeKey = data.rangeKey; + self.tableName = data.tableName; + self.timestamps = data.timestamps; - return uuidType; -}; + if(data.indexes) { + self.globalIndexes = _.chain(data.indexes).filter({ type: 'global' }).indexBy('name').value(); + self.secondaryIndexes = _.chain(data.indexes).filter({ type: 'local' }).indexBy('name').value(); + } -Schema.prototype.String = function (attrName, options) { - var attributeType = 'S'; - return internals.baseSetup(this, attrName, Joi.string(), attributeType, options); -}; + if(data.schema) { + self._modelSchema = _.isPlainObject(data.schema) ? Joi.object().keys(data.schema) : data.schema; + } else { + self._modelSchema = Joi.object(); + } -Schema.prototype.Number = function (attrName, options) { - var attributeType = 'N'; - return internals.baseSetup(this, attrName, Joi.number(), attributeType, options); -}; + if(self.timestamps) { + var extended = self._modelSchema.keys({ + createdAt : Joi.date(), + updatedAt : Joi.date() + }); -Schema.prototype.Binary = function (attrName, options) { - var attributeType = 'B'; - return internals.baseSetup(this, attrName, Schema.types.Binary(), attributeType, options); -}; + self._modelSchema = extended; + } -Schema.prototype.Boolean = function (attrName, options) { - var attributeType = 'N'; - return internals.baseSetup(this, attrName, Joi.boolean(), attributeType, options); + self._modelDatatypes = internals.parseDynamoTypes(self._modelSchema.describe()); + }); }; -Schema.prototype.Date = function (attrName, options) { - var attributeType = 'S'; - return internals.baseSetup(this, attrName, Joi.date(), attributeType, options); -}; +Schema.types = {}; -Schema.prototype.StringSet = function (attrName, options) { - var attributeType = 'SS'; - return internals.baseSetup(this, attrName, Schema.types.StringSet(), attributeType, options); -}; +Schema.types.stringSet = function () { + var set = Joi.array().includes(Joi.string()).meta({dynamoType : 'SS'}); -Schema.prototype.NumberSet = function (attrName, options) { - var attributeType = 'NS'; - return internals.baseSetup(this, attrName, Schema.types.NumberSet(), attributeType, options); + return set; }; -Schema.prototype.BinarySet = function (attrName, options) { - var attributeType = 'BS'; - return internals.baseSetup(this, attrName, Schema.types.BinarySet(), attributeType, options); +Schema.types.numberSet = function () { + var set = Joi.array().includes(Joi.number()).meta({dynamoType : 'NS'}); + return set; }; -Schema.prototype.UUID = function (attrName, options) { - var opts = _.merge({}, {default: nodeUUID.v4}, options); - - var attributeType = 'S'; - return internals.baseSetup(this, attrName, Schema.types.UUID(), attributeType, opts); +Schema.types.binarySet = function () { + var set = Joi.array().includes(Joi.binary(), Joi.string()).meta({dynamoType : 'BS'}); + return set; }; -Schema.prototype.TimeUUID = function (attrName, options) { - var opts = _.merge({}, {default: nodeUUID.v1}, options); - - var attributeType = 'S'; - return internals.baseSetup(this, attrName, Schema.types.TimeUUID(), attributeType, opts); +Schema.types.uuid = function () { + return Joi.string().guid().default(nodeUUID.v4); }; -Schema.prototype.validate = function (params) { - var schema = internals.buildSchemaObj(this.attrs); - - return Joi.validate(params, schema); +Schema.types.timeUUID = function () { + return Joi.string().guid().default(nodeUUID.v1); }; -Schema.prototype.globalIndex = function (name, keys) { - var schema = this; +Schema.prototype.validate = function (params, options) { + options = options || {}; - schema.globalIndexes[name] = keys; + return Joi.validate(params, this._modelSchema, options); }; -Schema.prototype.defaults = function () { - return _.reduce(this.attrs, function (result, attr, key) { - if(!_.isUndefined(attr.options.default)) { - result[key] = attr; +internals.invokeDefaultFunctions = function (data) { + return _.mapValues(data, function (val) { + if(_.isFunction(val)) { + return val.call(null); + } else if (_.isPlainObject(val)) { + return internals.invokeDefaultFunctions(val); + } else { + return val; } - - return result; - }, {}); + }); }; Schema.prototype.applyDefaults = function (data) { + var result = this.validate(data, {abortEarly : false}); - var defaults = _.reduce(this.defaults(), function (results, attr, key) { - if(_.isUndefined(data[key])) { - if(_.isFunction(attr.options.default)) { - results[key] = attr.options.default(); - } else { - results[key] = attr.options.default; - } - } - - return results; - }, {}); - - return _.defaults(data, defaults); + return internals.invokeDefaultFunctions(result.value); }; diff --git a/lib/serializer.js b/lib/serializer.js index b249f83..bcd1d19 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -1,246 +1,103 @@ 'use strict'; -var _ = require('lodash'); +var _ = require('lodash'), + utils = require('./utils'), + DOC = require('dynamodb-doc'); var serializer = module.exports; var internals = {}; -var serialize = internals.serialize = { - string : function (value) { - return {S: value}; - }, +internals.docClient = new DOC.DynamoDB(); - number: function (value) { - return {N: value.toString()}; - }, +internals.createSet = function(value, type) { + if(_.isArray(value) ) { + return internals.docClient.Set(value, type); + } else { + return internals.docClient.Set([value], type); + } +}; + +var serialize = internals.serialize = { binary: function (value) { - return {B: new Buffer(value).toString('base64')}; + if(_.isString(value)) { + return internals.docClient.StrToBin(value); + } + + return value; }, date : function (value) { if(_.isDate(value)) { - return {S: value.toISOString()}; + return value.toISOString(); } else { - return {S: new Date(value).toISOString()}; + return new Date(value).toISOString(); } }, boolean : function (value) { if (value && value !== 'false') { - return {N: '1'}; + return true; } else { - return {N: '0'}; + return false; } }, stringSet : function (value) { - if(_.isArray(value) ) { - return {SS: _.map(value, function (v) { return v.toString(); }) }; - } else { - return {SS: [value.toString()]}; - } + return internals.createSet(value, 'S'); }, numberSet : function (value) { - if(_.isArray(value) ) { - return {NS: _.map(value, function (v) { return Number(v).toString(); }) }; - } else { - return {NS: [Number(value).toString()]}; - } + return internals.createSet(value, 'N'); }, binarySet : function (value) { - if(_.isArray(value) ) { - return {BS: _.map(value, function (v) { return new Buffer(v).toString('base64'); }) }; - } else { - return {BS: [new Buffer(value).toString('base64')]}; - } - } - -}; - -var deserializer = internals.deserializer = { - number : function (value) { - if(value.N) { - return Number(value.N); - } else if (value.S) { - return Number(value.S); - } else if (value.B) { - return Number(new Buffer(value.B, 'base64')); - } else { - return null; - } - }, - - boolean: function (value) { - if(value.N) { - return Boolean(Number(value.N)); - } else if (value.S) { - return value.S === 'true'; - } else if (value.B) { - return new Buffer(value.B, 'base64').toString() === 'true'; - } else { - return false; - } - }, - - string : function (value) { - if(value.S) { - return value.S; - } else if (value.N) { - return value.N; - } else if (value.B) { - return new Buffer(value.B, 'base64').toString(); - } else { - return null; - } - }, - - binary : function (value) { - if(value.B) { - return new Buffer(value.B, 'base64'); - } else if (value.N) { - return new Buffer(value.N); - } else if (value.S) { - return new Buffer(value.S); - } else { - return null; - } - }, - - date : function (value) { - if (value.S) { - return new Date(value.S); - } else if (value.N) { - return new Date(value.N); - } else if (value.B) { - return new Date(new Buffer(value.B, 'base64')); - } else { - return null; - } - }, - - numberSet : function (value) { - if(value.NS) { - return _.map(value.NS, function (numString){ return Number(numString);}); - } else if (value.SS) { - return _.map(value.SS, function (numString){ return Number(numString);}); - } else if(value.BS) { - return _.map(value.BS, function (base64String){ return Number(new Buffer(base64String, 'base64'));}); - } else if (value.S) { - return [Number(value.S)]; - } else if (value.N) { - return [Number(value.N)]; - } else { - return []; - } - }, - - stringSet : function (value) { - if(value.SS) { - return value.SS; - } else if (value.NS) { - return value.NS; - } else if(value.BS) { - return _.map(value.BS, function (base64String){ return new Buffer(base64String, 'base64').toString();}); - } else if (value.S) { - return [value.S]; - } else if (value.N) { - return [value.N]; - } else { - return []; + var bins = value; + if(!_.isArray(value)) { + bins = [value]; } - }, - binarySet : function (value) { - if(value.BS) { - return _.map(value.BS, function (base64String){ return new Buffer(base64String, 'base64');}); - } else if(value.NS) { - return _.map(value.NS, function (number){ return new Buffer(number);}); - } else if (value.SS) { - return _.map(value.SS, function (string){ return new Buffer(string);}); - } else if (value.S) { - return [new Buffer(value.S)]; - } else if (value.N) { - return [new Buffer(value.N)]; - } else { - return []; - } + var vals = _.map(bins, serialize.binary); + return internals.createSet(vals, 'B'); } }; -internals.deserializeAttribute = function (value, attr) { - if(!value || !attr) { - return null; +internals.deserializeAttribute = function (value) { + if(_.isObject(value) && _.isFunction(value.toArray)) { + // value is a Set object from dynamodb-doc lib + return value.toArray(); + } else { + return value; } +}; - var type = attr.type._type; - - switch(type){ - case 'string': - case 'uuid': - case 'timeuuid': - return deserializer.string(value); - case 'number': - return deserializer.number(value); - case 'binary': - return deserializer.binary(value); - case 'date': - return deserializer.date(value); - case 'boolean': - return deserializer.boolean(value); - case 'numberSet': - return deserializer.numberSet(value); - case 'stringSet': - return deserializer.stringSet(value); - case 'binarySet': - return deserializer.binarySet(value); - default: - throw new Error('Unsupported schema type - ' + type); +internals.serializeAttribute = serializer.serializeAttribute = function (value, type, options) { + if(!type) { // if type is unknown, possibly because its an dynamic key return given value + return value; } -}; -internals.serializeAttribute = function (value, attr, options) { - if(!attr || _.isNull(value)) { + if(_.isNull(value)) { return null; } options = options || {}; - var type = attr.type._type; - switch(type){ - case 'string': - case 'uuid': - case 'timeuuid': - return serialize.string(value); - case 'number': - return serialize.number(value); - case 'binary': - return serialize.binary(value); - case 'date': + case 'DATE': return serialize.date(value); - case 'boolean': + case 'BOOL': return serialize.boolean(value); - case 'numberSet': - if(options.convertSets) { - return serialize.number(value); - } + case 'B': + return serialize.binary(value); + case 'NS': return serialize.numberSet(value); - case 'stringSet': - if(options.convertSets) { - return serialize.string(value); - } + case 'SS': return serialize.stringSet(value); - case 'binarySet': - if(options.convertSets) { - return serialize.binary(value); - } + case 'BS': return serialize.binarySet(value); default: - throw new Error('Unsupported schema type - ' + type); + return value; } }; @@ -250,10 +107,9 @@ serializer.buildKey = function (hashKey, rangeKey, schema) { if(_.isPlainObject(hashKey)) { obj[schema.hashKey] = hashKey[schema.hashKey]; - if(schema.rangeKey) { + if(schema.rangeKey && !_.isNull(hashKey[schema.rangeKey]) && !_.isUndefined(hashKey[schema.rangeKey])) { obj[schema.rangeKey] = hashKey[schema.rangeKey]; } - _.each(schema.globalIndexes, function (keys) { if(_.has(hashKey, keys.hashKey)){ obj[keys.hashKey] = hashKey[keys.hashKey]; @@ -264,16 +120,16 @@ serializer.buildKey = function (hashKey, rangeKey, schema) { } }); - _.each(schema.secondaryIndexes, function (rangeKey) { - if(_.has(hashKey, rangeKey)){ - obj[rangeKey] = hashKey[rangeKey]; + _.each(schema.secondaryIndexes, function (keys) { + if(_.has(hashKey, keys.rangeKey)){ + obj[keys.rangeKey] = hashKey[keys.rangeKey]; } }); } else { obj[schema.hashKey] = hashKey; - if(schema.rangeKey) { + if(schema.rangeKey && !_.isNull(rangeKey) && !_.isUndefined(rangeKey)) { obj[schema.rangeKey] = rangeKey; } } @@ -284,99 +140,87 @@ serializer.buildKey = function (hashKey, rangeKey, schema) { serializer.serializeItem = function (schema, item, options) { options = options || {}; - if(!item) { - return null; - } + var serialize = function (item, datatypes) { + datatypes = datatypes || {}; + + if(!item) { + return null; + } - var serialized = _.reduce(schema.attrs, function (result, attr, key) { - if(_.has(item, key)) { + return _.reduce(item, function (result, val, key) { + if(options.expected && _.isObject(val) && _.isBoolean(val.Exists)) { + result[key] = val; + return result; + } - if(options.expected && _.isObject(item[key]) && _.isBoolean(item[key].Exists)) { - result[key] = item[key]; + if(_.isPlainObject(val)) { + result[key] = serialize(val, datatypes[key]); return result; } - var val = internals.serializeAttribute(item[key], attr, options); + var attr = internals.serializeAttribute(val, datatypes[key], options); - if(!_.isNull(val) || options.returnNulls) { + if(!_.isNull(attr) || options.returnNulls) { if(options.expected) { - result[key] = {'Value' : val}; + result[key] = {'Value' : attr}; } else { - result[key] = val; + result[key] = attr; } } - } - return result; - }, {}); + return result; + }, {}); + }; - return serialized; + return serialize(item, schema._modelDatatypes); }; serializer.serializeItemForUpdate = function (schema, action, item) { - - return _.reduce(schema.attrs, function (result, attr, key) { - if(_.has(item, key) && key !== schema.hashKey && key !== schema.rangeKey) { - var value = item[key]; - if(_.isNull(value)) { - result[key] = {Action : 'DELETE'}; - } else if (_.isPlainObject(value) && value.$add) { - result[key] = {Action : 'ADD', Value: internals.serializeAttribute(value.$add, attr)}; - } else if (_.isPlainObject(value) && value.$del) { - result[key] = {Action : 'DELETE', Value: internals.serializeAttribute(value.$del, attr)}; - } else { - result[key] = {Action : action, Value: internals.serializeAttribute(value, attr)}; - } + var datatypes = schema._modelDatatypes; + + var data = utils.omitPrimaryKeys(schema, item); + return _.reduce(data, function (result, value, key) { + if(_.isNull(value)) { + result[key] = {Action : 'DELETE'}; + } else if (_.isPlainObject(value) && value.$add) { + result[key] = {Action : 'ADD', Value: internals.serializeAttribute(value.$add, datatypes[key])}; + } else if (_.isPlainObject(value) && value.$del) { + result[key] = {Action : 'DELETE', Value: internals.serializeAttribute(value.$del, datatypes[key])}; + } else { + result[key] = {Action : action, Value: internals.serializeAttribute(value, datatypes[key])}; } return result; }, {}); - }; -serializer.deserializeItem = function (schema, item) { +serializer.deserializeItem = function (item) { - if(!item) { + if(_.isNull(item)) { return null; } - var deserialized = _.reduce(schema.attrs, function (result, attr, key) { - var value = internals.deserializeAttribute(item[key], attr); + var formatter = function (data) { + var map = _.mapValues; - if(!_.isNull(value) && !_.isUndefined(value)) { - result[key] = value; + if(_.isArray(data)) { + map = _.map; } - return result; - }, {}); - - return deserialized; -}; - -serializer.deserializeKeys = function (schema, item) { - var result = {}; - - result[schema.hashKey] = internals.deserializeAttribute(item[schema.hashKey], schema.attrs[schema.hashKey]); + return map(data, function(value) { + var result; - if(schema.rangeKey) { - result[schema.rangeKey] = internals.deserializeAttribute(item[schema.rangeKey], schema.attrs[schema.rangeKey]); - } - - _.each(schema.globalIndexes, function (keys) { - if(item[keys.hashKey]){ - result[keys.hashKey] = internals.deserializeAttribute(item[keys.hashKey], schema.attrs[keys.hashKey]); - } - - if(item[keys.rangeKey]){ - result[keys.rangeKey] = internals.deserializeAttribute(item[keys.rangeKey], schema.attrs[keys.rangeKey]); - } - }); + if(_.isPlainObject(value)) { + result = formatter(value); + } else if(_.isArray(value)) { + result = formatter(value); + } else { + result = internals.deserializeAttribute(value); + } - _.each(schema.secondaryIndexes, function (rangekey) { - if(item[rangekey]){ - result[rangekey] = internals.deserializeAttribute(item[rangekey], schema.attrs[rangekey]); - } - }); + return result; + }); + }; - return result; + return formatter(item); }; diff --git a/lib/table.js b/lib/table.js index 8b74c6b..f9e0e2b 100644 --- a/lib/table.js +++ b/lib/table.js @@ -7,15 +7,16 @@ var _ = require('lodash'), EventEmitter = require('events').EventEmitter, async = require('async'), utils = require('./utils'), - ParallelScan = require('./parallelScan'); + ParallelScan = require('./parallelScan'), + expressions = require('./expressions'); var internals = {}; -var Table = module.exports = function (name, schema, serializer, dynamodb) { +var Table = module.exports = function (name, schema, serializer, docClient) { this.config = {name : name}; this.schema = schema; this.serializer = serializer; - this.dynamodb = dynamodb; + this.docClient = docClient; this._before = new EventEmitter(); this.before = this._before.on.bind(this._before); @@ -57,10 +58,6 @@ Table.prototype.get = function (hashKey, rangeKey, options, callback) { callback = rangeKey; options = {}; rangeKey = null; - } else if (_.isPlainObject(rangeKey) && !callback) { - callback = options; - options = rangeKey; - rangeKey = null; } else if (typeof options === 'function' && !callback) { callback = options; options = {}; @@ -73,14 +70,14 @@ Table.prototype.get = function (hashKey, rangeKey, options, callback) { params = _.merge({}, params, options); - self.dynamodb.getItem(params, function (err, data) { + self.docClient.getItem(params, function (err, data) { if(err) { return callback(err); } var item = null; if(data.Item) { - item = self.initItem(self.serializer.deserializeItem(self.schema, data.Item)); + item = self.initItem(self.serializer.deserializeItem(data.Item)); } return callback(null, item); @@ -101,10 +98,16 @@ Table.prototype.create = function (item, options, callback) { options = {}; } - callback = callback || function () {}; + callback = callback || _.noop; + options = options || {}; var start = function (callback) { var data = self.schema.applyDefaults(item); + + if(self.schema.timestamps && !_.has(data, 'createdAt')) { + data.createdAt = Date.now(); + } + return callback(null, data); }; @@ -113,10 +116,10 @@ Table.prototype.create = function (item, options, callback) { return callback(err); } - var validationError = self.schema.validate(data); + var result = self.schema.validate(data); - if(validationError) { - return callback(validationError); + if(result.error) { + return callback(result.error); } var attrs = utils.omitNulls(data); @@ -128,9 +131,12 @@ Table.prototype.create = function (item, options, callback) { if (options.expected) { params.Expected = self.serializer.serializeItem(self.schema, options.expected, {expected : true}); + options = _.omit(options, 'expected'); } - self.dynamodb.putItem(params, function (err) { + params = _.merge({}, params, options); + + self.docClient.putItem(params, function (err) { if(err) { return callback(err); } @@ -143,6 +149,36 @@ Table.prototype.create = function (item, options, callback) { }); }; +internals.updateExpressions = function (schema, data, options) { + var exp = expressions.serializeUpdateExpression(schema, data); + + if(options.UpdateExpression) { + var parsed = expressions.parse(options.UpdateExpression); + + exp.expressions = _.reduce(parsed, function (result, val, key) { + if(!_.isEmpty(val)) { + result[key] = result[key].concat(val); + } + + return result; + }, exp.expressions); + } + + if(_.isPlainObject(options.ExpressionAttributeValues)) { + exp.values = _.merge({}, exp.values, options.ExpressionAttributeValues); + } + + if(_.isPlainObject(options.ExpressionAttributeNames)) { + exp.attributeNames = _.merge({}, exp.attributeNames, options.ExpressionAttributeNames); + } + + return _.merge({}, { + ExpressionAttributeValues : exp.values, + ExpressionAttributeNames : exp.attributeNames, + UpdateExpression : expressions.stringify(exp.expressions), + }); +}; + Table.prototype.update = function (item, options, callback) { var self = this; @@ -151,9 +187,14 @@ Table.prototype.update = function (item, options, callback) { options = {}; } - callback = callback || function () {}; + callback = callback || _.noop; + options = options || {}; var start = function (callback) { + if(self.schema.timestamps && !_.has(item, 'updatedAt')) { + item.updatedAt = Date.now(); + } + return callback(null, item); }; @@ -168,26 +209,42 @@ Table.prototype.update = function (item, options, callback) { var params = { TableName : self.tableName(), Key : self.serializer.buildKey(hashKey, rangeKey, self.schema), - AttributeUpdates : self.serializer.serializeItemForUpdate(self.schema, 'PUT', data), ReturnValues : 'ALL_NEW' }; + var exp = internals.updateExpressions(self.schema, data, options); + + if(exp.UpdateExpression) { + params.UpdateExpression = exp.UpdateExpression; + delete options.UpdateExpression; + } + + if(exp.ExpressionAttributeValues) { + params.ExpressionAttributeValues = exp.ExpressionAttributeValues; + delete options.ExpressionAttributeValues; + } + + if(exp.ExpressionAttributeNames) { + params.ExpressionAttributeNames = exp.ExpressionAttributeNames; + delete options.ExpressionAttributeNames; + } + if (options.expected) { options.Expected = self.serializer.serializeItem(self.schema, options.expected, {expected : true}); delete options.expected; } - params = _.merge({}, params, options); + params = _.chain({}).merge(params, options).omit(_.isEmpty).value(); - self.dynamodb.updateItem(params, function (err, data) { + self.docClient.updateItem(params, function (err, data) { if(err) { return callback(err); } var result = null; if(data.Attributes) { - result = self.initItem(self.serializer.deserializeItem(self.schema, data.Attributes)); + result = self.initItem(self.serializer.deserializeItem(data.Attributes)); } self._after.emit('update', result); @@ -217,7 +274,8 @@ Table.prototype.destroy = function (hashKey, rangeKey, options, callback) { options = {}; } - callback = callback || function () {}; + callback = callback || _.noop; + options = options || {}; if (_.isPlainObject(hashKey)) { rangeKey = hashKey[self.schema.rangeKey] || null; @@ -237,14 +295,14 @@ Table.prototype.destroy = function (hashKey, rangeKey, options, callback) { params = _.merge({}, params, options); - self.dynamodb.deleteItem(params, function (err, data) { + self.docClient.deleteItem(params, function (err, data) { if(err) { return callback(err); } var item = null; if(data.Attributes) { - item = self.initItem(self.serializer.deserializeItem(self.schema, data.Attributes)); + item = self.initItem(self.serializer.deserializeItem(data.Attributes)); } self._after.emit('destroy', item); @@ -280,14 +338,14 @@ internals.deserializeItems = function (table, callback) { var result = {}; if(data.Items) { result.Items = _.map(data.Items, function(i) { - return table.initItem(table.serializer.deserializeItem(table.schema, i)); + return table.initItem(table.serializer.deserializeItem(i)); }); delete data.Items; } if(data.LastEvaluatedKey) { - result.LastEvaluatedKey = table.serializer.deserializeKeys(table.schema, data.LastEvaluatedKey); + result.LastEvaluatedKey = data.LastEvaluatedKey; delete data.LastEvaluatedKey; } @@ -300,25 +358,31 @@ internals.deserializeItems = function (table, callback) { Table.prototype.runQuery = function(params, callback) { var self = this; - self.dynamodb.query(params, internals.deserializeItems(self, callback)); + self.docClient.query(params, internals.deserializeItems(self, callback)); }; Table.prototype.runScan = function(params, callback) { var self = this; - self.dynamodb.scan(params, internals.deserializeItems(self, callback)); + self.docClient.scan(params, internals.deserializeItems(self, callback)); }; Table.prototype.runBatchGetItems = function (params, callback) { var self = this; - self.dynamodb.batchGetItem(params, callback); + self.docClient.batchGetItem(params, callback); }; internals.attributeDefinition = function (schema, key) { + var type = schema._modelDatatypes[key]; + + if(type === 'DATE') { + type = 'S'; + } + return { AttributeName : key, - AttributeType : schema.attrs[key].dynamoType + AttributeType : type }; }; @@ -338,20 +402,18 @@ internals.keySchema = function (hashKey, rangeKey) { return result; }; -internals.secondaryIndex = function (schema, indexKey) { - var indexName = indexKey + 'Index'; +internals.secondaryIndex = function (schema, params) { + var projection = params.projection || { ProjectionType : 'ALL' }; return { - IndexName : indexName, - KeySchema : internals.keySchema(schema.hashKey, indexKey), - Projection : { - ProjectionType : 'ALL' - } + IndexName : params.name, + KeySchema : internals.keySchema(schema.hashKey, params.rangeKey), + Projection : projection }; }; internals.globalIndex = function (indexName, params) { - var projection = params.Projection || { ProjectionType : 'ALL' }; + var projection = params.projection || { ProjectionType : 'ALL' }; return { IndexName : indexName, @@ -381,9 +443,9 @@ Table.prototype.createTable = function (options, callback) { var localSecondaryIndexes = []; - _.forEach(self.schema.secondaryIndexes, function (key) { - attributeDefinitions.push(internals.attributeDefinition(self.schema, key)); - localSecondaryIndexes.push(internals.secondaryIndex(self.schema, key)); + _.forEach(self.schema.secondaryIndexes, function (params) { + attributeDefinitions.push(internals.attributeDefinition(self.schema, params.rangeKey)); + localSecondaryIndexes.push(internals.secondaryIndex(self.schema, params)); }); var globalSecondaryIndexes = []; @@ -421,7 +483,7 @@ Table.prototype.createTable = function (options, callback) { params.GlobalSecondaryIndexes = globalSecondaryIndexes; } - self.dynamodb.createTable(params, callback); + self.docClient.createTable(params, callback); }; Table.prototype.describeTable = function (callback) { @@ -430,11 +492,21 @@ Table.prototype.describeTable = function (callback) { TableName : this.tableName(), }; - this.dynamodb.describeTable(params, callback); + this.docClient.describeTable(params, callback); +}; + +Table.prototype.deleteTable = function (callback) { + callback = callback || _.noop; + + var params = { + TableName : this.tableName(), + }; + + this.docClient.deleteTable(params, callback); }; Table.prototype.updateTable = function (throughput, callback) { - callback = callback || function () {}; + callback = callback || _.noop; var params = { TableName : this.tableName(), @@ -444,5 +516,5 @@ Table.prototype.updateTable = function (throughput, callback) { } }; - this.dynamodb.updateTable(params, callback); + this.docClient.updateTable(params, callback); }; diff --git a/lib/types/binary.js b/lib/types/binary.js deleted file mode 100644 index 77239e4..0000000 --- a/lib/types/binary.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict'; - -// Load modules - -var Any = require('joi/lib/any'); -var Errors = require('joi/lib/errors'); -var Utils = require('joi/lib/utils'); - - -// Declare internals - -var internals = {}; - -module.exports = internals.Binary = function () { - - Any.call(this); - this._type = 'binary'; - this._invalids.add(''); - this._invalids.add(new Buffer('')); - - this._base(function (value, state, options) { - - if (typeof value === 'string' || - value instanceof Buffer) { - return null; - } - - state.key = 'the value of ' + state.key + ' must be a either a string or a Buffer'; - return Errors.create('binary.base', null, state, options); - }); -}; - -Utils.inherits(internals.Binary, Any); - - -internals.Binary.create = function () { - return new internals.Binary(); -}; diff --git a/lib/utils.js b/lib/utils.js index 1942b3c..7614a30 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,7 +1,7 @@ 'use strict'; var _ = require('lodash'), - Readable = require('readable-stream'), + Readable = require('stream').Readable, async = require('async'); var utils = module.exports; @@ -65,25 +65,23 @@ utils.paginatedRequest = function (self, runRequestFunc, callback) { var lastEvaluatedKey = null; var responses = []; - - var performRequest = true; + var retry = false; var doFunc = function (callback) { if(lastEvaluatedKey) { self.startKey(lastEvaluatedKey); } - if(!performRequest) { - return setImmediate(callback); - } - runRequestFunc(self.buildRequest(), function (err, resp) { if(err && err.retryable) { + retry = true; return setImmediate(callback); } else if(err) { + retry = false; return setImmediate(callback, err); } + retry = false; lastEvaluatedKey = resp.LastEvaluatedKey; responses.push(resp); @@ -93,7 +91,7 @@ utils.paginatedRequest = function (self, runRequestFunc, callback) { }; var testFunc = function () { - return self.options.loadAll && lastEvaluatedKey; + return (self.options.loadAll && lastEvaluatedKey) || retry; }; var resulsFunc = function (err) { @@ -152,3 +150,7 @@ utils.streamRequest = function (self, runRequestFunc) { return stream; }; +utils.omitPrimaryKeys = function (schema, params) { + return _.omit(params, schema.hashKey, schema.rangeKey); +}; + diff --git a/package.json b/package.json index 2f3b638..6057481 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "vogels", - "version": "0.12.0", + "version": "2.0.0-rc6", "author": "Ryan Fitzgerald ", "description": "DynamoDB data mapper", "main": "index.js", "scripts": { - "test": "grunt test" + "test": "make test" }, "repository": "git://github.com/ryanfitz/vogels.git", "keywords": [ @@ -16,25 +16,24 @@ "nosql" ], "engines": { - "node": ">=0.10.22" + "node": ">=0.10.30" }, "license": "MIT", "dependencies": { - "aws-sdk": "2.0.19", + "aws-sdk": "2.1.x", + "dynamodb-doc": "awslabs/dynamodb-document-js-sdk", "lodash": "2.4.x", - "joi": "2.9.x", + "joi": "5.x.x", "node-uuid": "1.4.x", - "async": "0.9.x", - "readable-stream": "~1.0.26-2" + "async": "0.9.x" }, "devDependencies": { "chai": "1.x.x", - "grunt": "0.4.x", - "grunt-cli": "0.1.x", - "grunt-contrib-jshint": "0.10.x", - "grunt-contrib-watch": "0.6.x", - "grunt-simple-mocha": "0.4.x", - "mocha": "1.x.x", - "sinon": "1.9.x" + "istanbul": "^0.3.5", + "jscoverage": "^0.5.9", + "jshint": "2.x.x", + "jshint-stylish": "^1.0.0", + "mocha": "2.x.x", + "sinon": "1.12.x" } } diff --git a/test/batch-test.js b/test/batch-test.js index a43913c..08bbd62 100644 --- a/test/batch-test.js +++ b/test/batch-test.js @@ -1,41 +1,59 @@ 'use strict'; var helper = require('./test-helper'), + chai = require('chai'), + expect = chai.expect, Schema = require('../lib/schema'), Item = require('../lib/item'), batch = require('../lib/batch'), + Serializer = require('../lib/serializer'), + Joi = require('joi'), _ = require('lodash'); describe('Batch', function () { - var schema, - serializer, - table; + var serializer, + table; beforeEach(function () { - schema = new Schema(); serializer = helper.mockSerializer(), table = helper.mockTable(); + table.serializer = Serializer; table.tableName = function () { return 'accounts'; }; - table.schema = schema; + + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + table.schema = new Schema(config); }); describe('#getItems', function () { it('should get items by hash key', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; - serializer.buildKey.withArgs('test@test.com').returns({email : {S : 'test@test.com'}}); - serializer.buildKey.withArgs('foo@example.com').returns({email : {S : 'foo@example.com'}}); + table.schema = new Schema(config); var response = { Responses : { accounts : [ - {email : {S: 'test@test.com'}, name : {S : 'Tim Tester'}}, - {email : {S: 'foo@example.com'}, name : {S : 'Foo Bar'}} + {email : 'test@test.com', name : 'Tim Tester'}, + {email : 'foo@example.com', name : 'Foo Bar'} ] } }; @@ -44,8 +62,8 @@ describe('Batch', function () { RequestItems : { accounts : { Keys : [ - {email : {S : 'test@test.com'}}, - {email : {S : 'foo@example.com'}} + {email : 'test@test.com'}, + {email : 'foo@example.com'} ] } } @@ -53,11 +71,10 @@ describe('Batch', function () { var item1 = {email: 'test@test.com', name : 'Tim Tester'}; table.runBatchGetItems.withArgs(expectedRequest).yields(null, response); - serializer.deserializeItem.returns(item1); table.initItem.returns(new Item(item1)); - batch(table, serializer).getItems(['test@test.com', 'foo@example.com'], function (err, items) { + batch(table, Serializer).getItems(['test@test.com', 'foo@example.com'], function (err, items) { items.should.have.length(2); items[0].get('email').should.equal('test@test.com'); @@ -66,21 +83,14 @@ describe('Batch', function () { }); it('should get items by hash and range key', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age'); - var key1 = {email: 'test@test.com', name : 'Tim Tester'}; var key2 = {email: 'foo@example.com', name : 'Foo Bar'}; - serializer.buildKey.withArgs(key1).returns({email : {S : key1.email}, name : {S: key1.name}}); - serializer.buildKey.withArgs(key2).returns({email : {S : key2.email}, name : {S: key2.name}}); - var response = { Responses : { accounts : [ - {email : {S: 'test@test.com'}, name : {S : 'Tim Tester'}}, - {email : {S: 'foo@example.com'}, name : {S : 'Foo Bar'}} + {email : 'test@test.com', name : 'Tim Tester'}, + {email : 'foo@example.com', name : 'Foo Bar'} ] } }; @@ -89,8 +99,8 @@ describe('Batch', function () { RequestItems : { accounts : { Keys : [ - {email : {S : key1.email}, name : {S: key1.name}}, - {email : {S : key2.email}, name : {S: key2.name}} + {email : key1.email, name : key1.name}, + {email : key2.email, name : key2.name} ] } } @@ -98,11 +108,10 @@ describe('Batch', function () { var item1 = {email: 'test@test.com', name : 'Tim Tester', age: 22}; table.runBatchGetItems.withArgs(expectedRequest).yields(null, response); - serializer.deserializeItem.returns(item1); table.initItem.returns(new Item(item1)); - batch(table, serializer).getItems([key1, key2], function (err, items) { + batch(table, Serializer).getItems([key1, key2], function (err, items) { items.should.have.length(2); items[0].get('email').should.equal('test@test.com'); @@ -111,10 +120,6 @@ describe('Batch', function () { }); it('should not modify passed in keys', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age'); - var keys = _.map(_.range(300), function (num) { var key = {email: 'test' + num + '@test.com', name : 'Test ' + num}; serializer.buildKey.withArgs(key).returns({email : {S : key.email}, name : {S: key.name}}); @@ -139,6 +144,166 @@ describe('Batch', function () { }); }); + it('should get items by hash key with consistent read', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var response = { + Responses : { + accounts : [ + {email : 'test@test.com', name :'Tim Tester'}, + {email : 'foo@example.com', name : 'Foo Bar'} + ] + } + }; + + var expectedRequest = { + RequestItems : { + accounts : { + Keys : [ + {email : 'test@test.com'}, + {email : 'foo@example.com'} + ], + ConsistentRead : true + } + } + }; + + var item1 = {email: 'test@test.com', name : 'Tim Tester'}; + table.runBatchGetItems.withArgs(expectedRequest).yields(null, response); + + table.initItem.returns(new Item(item1)); + + batch(table, Serializer).getItems(['test@test.com', 'foo@example.com'], {ConsistentRead : true}, function (err, items) { + items.should.have.length(2); + items[0].get('email').should.equal('test@test.com'); + + done(); + }); + }); + + it('should get items by hash key with projection expression', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var response = { + Responses : { + accounts : [ + {email : 'test@test.com', name :'Tim Tester'}, + {email : 'foo@example.com', name : 'Foo Bar'} + ] + } + }; + + var expectedRequest = { + RequestItems : { + accounts : { + Keys : [ + {email : 'test@test.com'}, + {email : 'foo@example.com'} + ], + ProjectionExpression : '#name, #e', + ExpressionAttributeNames : { '#name' : 'name', '#email' : 'email'} + } + } + }; + + var item1 = {email: 'test@test.com', name : 'Tim Tester'}; + table.runBatchGetItems.withArgs(expectedRequest).yields(null, response); + + table.initItem.returns(new Item(item1)); + + var opts = { + ProjectionExpression : '#name, #e', + ExpressionAttributeNames : { '#name' : 'name', '#email' : 'email'} + }; + + batch(table, Serializer).getItems(['test@test.com', 'foo@example.com'], opts, function (err, items) { + items.should.have.length(2); + items[0].get('email').should.equal('test@test.com'); + + done(); + }); + }); + + it('should get items when encounters retryable excpetion', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var response = { + Responses : { + accounts : [ + {email : 'test@test.com', name :'Tim Tester'}, + {email : 'foo@example.com', name : 'Foo Bar'} + ] + } + }; + + var item1 = {email: 'test@test.com', name : 'Tim Tester'}; + + var err = new Error('RetryableException'); + err.retryable = true; + + table.runBatchGetItems + .onCall(0).yields(err) + .onCall(1).yields(null, response); + + table.initItem.returns(new Item(item1)); + + batch(table, Serializer).getItems(['test@test.com', 'foo@example.com'], function (err, items) { + expect(err).to.not.exist; + + expect(table.runBatchGetItems.calledTwice).to.be.true; + items.should.have.length(2); + items[0].get('email').should.equal('test@test.com'); + + done(); + }); + }); + + it('should return error', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var err = new Error('Error'); + table.runBatchGetItems.onCall(0).yields(err); + + batch(table, Serializer).getItems(['test@test.com', 'foo@example.com'], function (err, items) { + expect(err).to.exist; + expect(items).to.not.exist; + + expect(table.runBatchGetItems.calledOnce).to.be.true; + done(); + }); + }); }); }); diff --git a/test/expressions-test.js b/test/expressions-test.js new file mode 100644 index 0000000..a9fca00 --- /dev/null +++ b/test/expressions-test.js @@ -0,0 +1,398 @@ +'use strict'; + +var expressions = require('../lib/expressions'), + chai = require('chai'), + expect = chai.expect, + Schema = require('../lib/schema'), + Joi = require('joi'); + //_ = require('lodash'); + +chai.should(); + +describe('expressions', function () { + + describe('#parse', function () { + + it('should parse single SET action', function () { + var out = expressions.parse('SET foo = :x'); + + expect(out).to.eql({ + SET : ['foo = :x'], + ADD : null, + REMOVE : null, + DELETE : null + }); + }); + + it('should parse multiple SET actions', function () { + var out = expressions.parse('SET num = num + :n,Price = if_not_exists(Price, 100), #pr.FiveStar = list_append(#pr.FiveStar, :r)'); + + expect(out).to.eql({ + SET : ['num = num + :n', 'Price = if_not_exists(Price, 100)', '#pr.FiveStar = list_append(#pr.FiveStar, :r)'], + ADD : null, + REMOVE : null, + DELETE : null + }); + }); + + it('should parse ADD action', function () { + var out = expressions.parse('ADD num :y'); + + expect(out).to.eql({ + SET : null, + ADD : ['num :y'], + REMOVE : null, + DELETE : null + }); + }); + + it('should parse REMOVE action', function () { + var out = expressions.parse('REMOVE Title, RelatedItems[2], Pictures.RearView'); + + expect(out).to.eql({ + SET : null, + ADD : null, + REMOVE : ['Title', 'RelatedItems[2]', 'Pictures.RearView'], + DELETE : null + }); + }); + + + it('should parse DELETE action', function () { + var out = expressions.parse('DELETE color :c'); + + expect(out).to.eql({ + SET : null, + ADD : null, + REMOVE : null, + DELETE : ['color :c'] + }); + }); + + it('should parse ADD and SET actions', function () { + var out = expressions.parse('ADD num :y SET name = :n'); + + expect(out).to.eql({ + SET : ['name = :n'], + ADD : ['num :y'], + REMOVE : null, + DELETE : null + }); + }); + + it('should parse multiple actions', function () { + var out = expressions.parse('SET list[0] = :val1 REMOVE #m.nestedField1, #m.nestedField2 ADD aNumber :val2, anotherNumber :val3 DELETE aSet :val4'); + + expect(out).to.eql({ + SET : ['list[0] = :val1'], + ADD : ['aNumber :val2', 'anotherNumber :val3'], + REMOVE : ['#m.nestedField1', '#m.nestedField2'], + DELETE : ['aSet :val4'] + }); + }); + + it('should return null actions when given null', function () { + var out = expressions.parse(null); + + expect(out).to.eql({ + SET : null, + ADD : null, + REMOVE : null, + DELETE : null + }); + }); + + it('should return null actions when given empty string', function () { + var out = expressions.parse(''); + + expect(out).to.eql({ + SET : null, + ADD : null, + REMOVE : null, + DELETE : null + }); + }); + }); + + describe('#serializeUpdateExpression', function () { + var schema; + + beforeEach(function () { + var config = { + hashKey: 'id', + schema : { + id : Joi.string(), + email : Joi.string(), + age : Joi.number(), + names : Schema.types.stringSet() + } + }; + + schema = new Schema(config); + }); + + it('should return single SET action', function () { + var updates = { + id : 'foobar', + email : 'test@test.com', + }; + + var result = expressions.serializeUpdateExpression(schema, updates); + + expect(result.expressions).to.eql({ + SET : ['#email = :email'], + ADD : [], + REMOVE : [], + DELETE : [], + }); + + expect(result.values).to.eql({ ':email' : 'test@test.com' }); + expect(result.attributeNames).to.eql({ '#email' : 'email' }); + }); + + it('should return multiple SET actions', function () { + var updates = { + id : 'foobar', + email : 'test@test.com', + age : 33, + name : 'Steve' + }; + + var result = expressions.serializeUpdateExpression(schema, updates); + + expect(result.expressions).to.eql({ + SET : ['#email = :email', '#age = :age', '#name = :name'], + ADD : [], + REMOVE : [], + DELETE : [], + }); + + expect(result.values).to.eql({ + ':email' : 'test@test.com', + ':age' : 33, + ':name' : 'Steve' + }); + + expect(result.attributeNames).to.eql({ + '#email' : 'email', + '#age' : 'age', + '#name' : 'name', + }); + }); + + it('should return SET and ADD actions', function () { + var updates ={ + id : 'foobar', + email : 'test@test.com', + age : {$add : 1} + }; + + var result = expressions.serializeUpdateExpression(schema, updates); + expect(result.expressions).to.eql({ + SET : ['#email = :email'], + ADD : ['#age :age'], + REMOVE : [], + DELETE : [], + }); + + expect(result.values).to.eql({ + ':email' : 'test@test.com', + ':age' : 1 + }); + + expect(result.attributeNames).to.eql({ + '#email' : 'email', + '#age' : 'age', + }); + + }); + + it('should return single DELETE action', function () { + var updates = { + id : 'foobar', + names : { $del : 'tester'}, + }; + + var result = expressions.serializeUpdateExpression(schema, updates); + + expect(result.expressions).to.eql({ + SET : [], + ADD : [], + REMOVE : [], + DELETE : ['#names :names'], + }); + + var stringSet = result.values[':names']; + + expect(result.values).to.have.keys([':names']); + expect(result.values[':names'].datatype).eql('SS'); + expect(stringSet.format()).to.eql({ SS : [ 'tester'] }); + + expect(result.attributeNames).to.eql({ + '#names' : 'names' + }); + + }); + + it('should return single REMOVE action', function () { + var updates = { + id : 'foobar', + email : null, + }; + + var result = expressions.serializeUpdateExpression(schema, updates); + + expect(result.expressions).to.eql({ + SET : [], + ADD : [], + REMOVE : ['#email'], + DELETE : [], + }); + + expect(result.values).to.eql({}); + + expect(result.attributeNames).to.eql({ + '#email' : 'email' + }); + + }); + + it('should return empty actions when passed empty object', function () { + var result = expressions.serializeUpdateExpression(schema, {}); + + expect(result.expressions).to.eql({ + SET : [], + ADD : [], + REMOVE : [], + DELETE : [], + }); + + expect(result.values).to.eql({}); + expect(result.attributeNames).to.eql({}); + }); + + it('should return empty actions when passed null', function () { + var result = expressions.serializeUpdateExpression(schema, null); + + expect(result.expressions).to.eql({ + SET : [], + ADD : [], + REMOVE : [], + DELETE : [], + }); + + expect(result.values).to.eql({}); + expect(result.attributeNames).to.eql({}); + }); + + }); + + describe('#stringify', function () { + + it('should return single SET action', function () { + var params = { + SET : ['#email = :email'] + }; + + var out = expressions.stringify(params); + expect(out).to.eql('SET #email = :email'); + }); + + it('should return single SET action when param is a string', function () { + var params = { + SET : '#email = :email' + }; + + var out = expressions.stringify(params); + expect(out).to.eql('SET #email = :email'); + }); + + it('should return single SET action when other actions are null', function () { + var params = { + SET : ['#email = :email'], + ADD : null, + REMOVE : null, + DELETE : null + }; + + var out = expressions.stringify(params); + expect(out).to.eql('SET #email = :email'); + }); + + it('should return multiple SET actions', function () { + var params = { + SET : ['#email = :email', '#age = :n', '#name = :name'] + }; + + var out = expressions.stringify(params); + expect(out).to.eql('SET #email = :email, #age = :n, #name = :name'); + }); + + it('should return SET and ADD actions', function () { + var params = { + SET : ['#email = :email'], + ADD : ['#age :n', '#foo :bar'] + }; + + var out = expressions.stringify(params); + expect(out).to.eql('SET #email = :email ADD #age :n, #foo :bar'); + }); + + it('should return stringified ALL actions', function () { + var params = { + SET : ['#email = :email'], + ADD : ['#age :n', '#foo :bar'], + REMOVE : ['#title', '#picture', '#settings'], + DELETE : ['#color :c'] + }; + + var out = expressions.stringify(params); + expect(out).to.eql('SET #email = :email ADD #age :n, #foo :bar REMOVE #title, #picture, #settings DELETE #color :c'); + }); + + it('should return empty string when passed empty actions', function () { + var params = { + SET : [], + ADD : [], + REMOVE : [], + DELETE : [] + }; + + var out = expressions.stringify(params); + expect(out).to.eql(''); + }); + + it('should return empty string when passed null actions', function () { + var params = { + SET : null, + ADD : null, + REMOVE : null, + DELETE : null + }; + + var out = expressions.stringify(params); + expect(out).to.eql(''); + }); + + it('should return empty string when passed empty object', function () { + var out = expressions.stringify({}); + + expect(out).to.eql(''); + }); + + it('should return empty string when passed null', function () { + var out = expressions.stringify(null); + + expect(out).to.eql(''); + }); + + it('should result from stringifying a parsed string should equal original string', function () { + var exp = 'SET #email = :email ADD #age :n, #foo :bar REMOVE #title, #picture, #settings DELETE #color :c'; + var parsed = expressions.parse(exp); + + expect(expressions.stringify(parsed)).to.eql(exp); + }); + + }); + +}); diff --git a/test/index-test.js b/test/index-test.js index c0177a2..e968eb9 100644 --- a/test/index-test.js +++ b/test/index-test.js @@ -3,9 +3,10 @@ var vogels = require('../index'), helper = require('./test-helper'), Table = require('../lib/table'), - Schema = require('../lib/schema'), chai = require('chai'), - expect = chai.expect; + expect = chai.expect, + Joi = require('joi'), + sinon = require('sinon'); chai.should(); @@ -17,15 +18,30 @@ describe('vogels', function () { describe('#define', function () { - it('should invoke callback with instance of a schema', function (done) { - vogels.define('Account', function (schema) { - schema.should.be.instanceof(Schema); - done(); - }); + it('should return model', function () { + var config = { + hashKey : 'name', + schema : { + name : Joi.string() + } + }; + + var model = vogels.define('Account', config); + expect(model).to.not.be.nil; + }); + + it('should throw when using old api', function () { + + expect(function () { + vogels.define('Account', function (schema) { + schema.String('email', {hashKey: true}); + }); + + }).to.throw(/define no longer accepts schema callback, migrate to new api/); }); it('should have config method', function () { - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); Account.config({tableName: 'test-accounts'}); @@ -33,13 +49,13 @@ describe('vogels', function () { }); it('should configure table name as accounts', function () { - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); Account.config().name.should.equal('accounts'); }); it('should return new account item', function () { - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); var acc = new Account({name: 'Test Acc'}); acc.table.should.be.instanceof(Table); @@ -54,7 +70,7 @@ describe('vogels', function () { }); it('should contain single model', function () { - vogels.define('Account'); + vogels.define('Account', {hashKey : 'id'}); vogels.models.should.contain.keys('Account'); }); @@ -63,7 +79,7 @@ describe('vogels', function () { describe('#model', function () { it('should return defined model', function () { - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); vogels.model('Account').should.equal(Account); }); @@ -76,7 +92,7 @@ describe('vogels', function () { describe('model config', function () { it('should configure set dynamodb driver', function () { - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); Account.config({tableName: 'test-accounts' }); @@ -84,33 +100,145 @@ describe('vogels', function () { }); it('should configure set dynamodb driver', function () { - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); - var dynamodb = helper.mockDynamoDB(); + var dynamodb = helper.realDynamoDB(); Account.config({dynamodb: dynamodb }); - Account.dynamodb.should.eq(dynamodb); + Account.docClient.should.eq(dynamodb); }); it('should globally set dynamodb driver for all models', function () { - var Account = vogels.define('Account'); - var Post = vogels.define('Post'); + var Account = vogels.define('Account', {hashKey : 'id'}); + var Post = vogels.define('Post', {hashKey : 'id'}); - var dynamodb = helper.mockDynamoDB(); + var dynamodb = helper.realDynamoDB(); vogels.dynamoDriver(dynamodb); - Account.dynamodb.should.eq(dynamodb); - Post.dynamodb.should.eq(dynamodb); + Account.docClient.should.eq(dynamodb); + Post.docClient.should.eq(dynamodb); }); it('should continue to use globally set dynamodb driver', function () { var dynamodb = helper.mockDynamoDB(); vogels.dynamoDriver(dynamodb); - var Account = vogels.define('Account'); + var Account = vogels.define('Account', {hashKey : 'id'}); - Account.dynamodb.should.eq(dynamodb); + Account.docClient.should.eq(dynamodb); }); }); + + describe('#createTables', function () { + var clock; + + beforeEach(function () { + vogels.reset(); + var dynamodb = helper.mockDynamoDB(); + vogels.dynamoDriver(dynamodb); + clock = sinon.useFakeTimers(); + }); + + afterEach(function () { + clock.restore(); + }); + + it('should create single definied model', function (done) { + this.timeout(0); + + var Account = vogels.define('Account', {hashKey : 'id'}); + + var second = { + Table : { TableStatus : 'PENDING'} + }; + + var third = { + Table : { TableStatus : 'ACTIVE'} + }; + + Account.docClient.describeTable + .onCall(0).yields(null, null) + .onCall(1).yields(null, second) + .onCall(2).yields(null, third); + + Account.docClient.createTable.yields(null, null); + + vogels.createTables(function (err) { + expect(err).to.not.exist; + expect(Account.docClient.describeTable.calledThrice).to.be.true; + return done(); + }); + + clock.tick(1200); + clock.tick(1200); + }); + + it('should return error', function (done) { + var Account = vogels.define('Account', {hashKey : 'id'}); + + Account.docClient.describeTable.onCall(0).yields(null, null); + + Account.docClient.createTable.yields(new Error('Fail'), null); + + vogels.createTables(function (err) { + expect(err).to.exist; + expect(Account.docClient.describeTable.calledOnce).to.be.true; + return done(); + }); + }); + + it('should create model without callback', function (done) { + var Account = vogels.define('Account', {hashKey : 'id'}); + + var second = { + Table : { TableStatus : 'PENDING'} + }; + + var third = { + Table : { TableStatus : 'ACTIVE'} + }; + + Account.docClient.describeTable + .onCall(0).yields(null, null) + .onCall(1).yields(null, second) + .onCall(2).yields(null, third); + + Account.docClient.createTable.yields(null, null); + + vogels.createTables(); + + clock.tick(1200); + clock.tick(1200); + + expect(Account.docClient.describeTable.calledThrice).to.be.true; + return done(); + }); + + it('should return error when waiting for table to become active', function (done) { + var Account = vogels.define('Account', {hashKey : 'id'}); + + var second = { + Table : { TableStatus : 'PENDING'} + }; + + Account.docClient.describeTable + .onCall(0).yields(null, null) + .onCall(1).yields(null, second) + .onCall(2).yields(new Error('fail')); + + Account.docClient.createTable.yields(null, null); + + vogels.createTables(function (err) { + expect(err).to.exist; + expect(Account.docClient.describeTable.calledThrice).to.be.true; + return done(); + }); + + clock.tick(1200); + clock.tick(1200); + }); + + }); + }); diff --git a/test/integration/create-table-test.js b/test/integration/create-table-test.js new file mode 100644 index 0000000..7c7cbee --- /dev/null +++ b/test/integration/create-table-test.js @@ -0,0 +1,391 @@ +'use strict'; + +var vogels = require('../../index'), + chai = require('chai'), + expect = chai.expect, + //async = require('async'), + _ = require('lodash'), + helper = require('../test-helper'), + Joi = require('joi'); + +chai.should(); + +describe('Create Tables Integration Tests', function() { + this.timeout(0); + + before(function () { + vogels.dynamoDriver(helper.realDynamoDB()); + }); + + afterEach(function () { + vogels.reset(); + }); + + it('should create table with hash key', function (done) { + var Model = vogels.define('vogels-create-table-test', { + hashKey : 'id', + tableName : helper.randomName('vogels-createtable-Accounts'), + schema : { + id : Joi.string(), + } + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + expect(desc).to.exist; + expect(desc.KeySchema).to.eql([{ AttributeName: 'id', KeyType: 'HASH' } ]); + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'id', AttributeType: 'S' }, + ]); + + return Model.deleteTable(done); + }); + }); + + it('should create table with hash and range key', function (done) { + var Model = vogels.define('vogels-createtable-rangekey', { + hashKey : 'name', + rangeKey : 'age', + tableName : helper.randomName('vogels-createtable-rangekey'), + schema : { + name : Joi.string(), + age : Joi.number(), + } + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + + expect(desc).to.exist; + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'name', AttributeType: 'S' }, + { AttributeName: 'age', AttributeType: 'N' } + ]); + + expect(desc.KeySchema).to.eql([ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'age', KeyType: 'RANGE' } + ]); + + return Model.deleteTable(done); + }); + }); + + it('should create table with local secondary index', function (done) { + var Model = vogels.define('vogels-createtable-rangekey', { + hashKey : 'name', + rangeKey : 'age', + tableName : helper.randomName('vogels-createtable-local-idx'), + schema : { + name : Joi.string(), + age : Joi.number(), + nick : Joi.string(), + time : Joi.date() + }, + indexes : [ + {hashKey : 'name', rangeKey : 'nick', type : 'local', name : 'NickIndex'}, + {hashKey : 'name', rangeKey : 'time', type : 'local', name : 'TimeIndex'}, + ] + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + + expect(desc).to.exist; + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'name', AttributeType: 'S' }, + { AttributeName: 'age', AttributeType: 'N' }, + { AttributeName: 'nick', AttributeType: 'S' }, + { AttributeName: 'time', AttributeType: 'S' } + ]); + + expect(desc.KeySchema).to.eql([ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'age', KeyType: 'RANGE' } + ]); + + expect(desc.LocalSecondaryIndexes).to.have.length(2); + + expect(_.find(desc.LocalSecondaryIndexes, { IndexName: 'NickIndex' })).to.eql({ + IndexName: 'NickIndex', + KeySchema:[ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'nick', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + IndexSizeBytes: 0, + ItemCount: 0 + }); + + expect(_.find(desc.LocalSecondaryIndexes, { IndexName: 'TimeIndex' })).to.eql({ + IndexName: 'TimeIndex', + KeySchema:[ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'time', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + IndexSizeBytes: 0, + ItemCount: 0 + }); + + return Model.deleteTable(done); + }); + }); + + it('should create table with local secondary index with custom projection', function (done) { + var Model = vogels.define('vogels-createtable-local-proj', { + hashKey : 'name', + rangeKey : 'age', + tableName : helper.randomName('vogels-createtable-local-proj'), + schema : { + name : Joi.string(), + age : Joi.number(), + nick : Joi.string() + }, + indexes : [{ + hashKey : 'name', + rangeKey : 'nick', + type : 'local', + name : 'KeysOnlyNickIndex', + projection : { ProjectionType: 'KEYS_ONLY'} + }] + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + + expect(desc).to.exist; + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'name', AttributeType: 'S' }, + { AttributeName: 'age', AttributeType: 'N' }, + { AttributeName: 'nick', AttributeType: 'S' } + ]); + + expect(desc.KeySchema).to.eql([ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'age', KeyType: 'RANGE' } + ]); + + expect(desc.LocalSecondaryIndexes).to.eql([ + { IndexName: 'KeysOnlyNickIndex', + KeySchema:[ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'nick', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'KEYS_ONLY' }, + IndexSizeBytes: 0, + ItemCount: 0 } + ]); + + return Model.deleteTable(done); + }); + }); + + it('should create table with global index', function (done) { + var Model = vogels.define('vogels-createtable-global', { + hashKey : 'name', + rangeKey : 'age', + tableName : helper.randomName('vogels-createtable-global'), + schema : { + name : Joi.string(), + age : Joi.number(), + nick : Joi.string() + }, + indexes : [{hashKey : 'nick', type : 'global', name : 'GlobalNickIndex'}] + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + + expect(desc).to.exist; + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'name', AttributeType: 'S' }, + { AttributeName: 'age', AttributeType: 'N' }, + { AttributeName: 'nick', AttributeType: 'S' } + ]); + + expect(desc.KeySchema).to.eql([ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'age', KeyType: 'RANGE' } + ]); + + expect(desc.GlobalSecondaryIndexes).to.eql([ + { IndexName: 'GlobalNickIndex', + KeySchema:[ + { AttributeName: 'nick', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput : { ReadCapacityUnits : 1, WriteCapacityUnits : 1}, + IndexSizeBytes: 0, + IndexStatus : 'ACTIVE', + ItemCount: 0 } + ]); + + return Model.deleteTable(done); + }); + }); + + it('should create table with global index with optional settings', function (done) { + var Model = vogels.define('vogels-createtable-global', { + hashKey : 'name', + rangeKey : 'age', + tableName : helper.randomName('vogels-createtable-global'), + schema : { + name : Joi.string(), + age : Joi.number(), + nick : Joi.string(), + wins : Joi.number() + }, + indexes : [{ + hashKey : 'nick', + type : 'global', + name : 'GlobalNickIndex', + projection : { NonKeyAttributes : [ 'wins' ], ProjectionType : 'INCLUDE' }, + readCapacity : 10, + writeCapacity : 5 + }] + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + + expect(desc).to.exist; + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'name', AttributeType: 'S' }, + { AttributeName: 'age', AttributeType: 'N' }, + { AttributeName: 'nick', AttributeType: 'S' } + ]); + + expect(desc.KeySchema).to.eql([ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'age', KeyType: 'RANGE' } + ]); + + expect(desc.GlobalSecondaryIndexes).to.eql([ + { IndexName: 'GlobalNickIndex', + KeySchema:[ + { AttributeName: 'nick', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'INCLUDE', NonKeyAttributes : [ 'wins' ] }, + ProvisionedThroughput : { ReadCapacityUnits : 10, WriteCapacityUnits : 5}, + IndexSizeBytes: 0, + IndexStatus : 'ACTIVE', + ItemCount: 0 } + ]); + + return Model.deleteTable(done); + }); + }); + + it('should create table with global and local indexes', function (done) { + var Model = vogels.define('vogels-createtable-both-indexes', { + hashKey : 'name', + rangeKey : 'age', + tableName : helper.randomName('vogels-createtable-both-indexes'), + schema : { + name : Joi.string(), + age : Joi.number(), + nick : Joi.string(), + wins : Joi.number() + }, + indexes : [ + { hashKey : 'name', rangeKey : 'nick', type : 'local', name : 'NameNickIndex'}, + { hashKey : 'name', rangeKey : 'wins', type : 'local', name : 'NameWinsIndex'}, + { hashKey : 'nick', type : 'global', name : 'GlobalNickIndex' }, + { hashKey : 'age' , rangeKey : 'wins', type : 'global', name : 'GlobalAgeWinsIndex' } + ] + }); + + Model.createTable(function (err, result) { + expect(err).to.not.exist; + + var desc = result.TableDescription; + + expect(desc).to.exist; + + expect(desc.AttributeDefinitions).to.eql([ + { AttributeName: 'name', AttributeType: 'S' }, + { AttributeName: 'age', AttributeType: 'N' }, + { AttributeName: 'nick', AttributeType: 'S' }, + { AttributeName: 'wins', AttributeType: 'N' } + ]); + + expect(desc.KeySchema).to.eql([ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'age', KeyType: 'RANGE' } + ]); + + expect(desc.GlobalSecondaryIndexes).to.have.length(2); + + expect(_.find(desc.GlobalSecondaryIndexes, { IndexName: 'GlobalNickIndex' })).to.eql({ + IndexName: 'GlobalNickIndex', + KeySchema:[ + { AttributeName: 'nick', KeyType: 'HASH' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput : { ReadCapacityUnits : 1, WriteCapacityUnits : 1}, + IndexSizeBytes: 0, + IndexStatus : 'ACTIVE', + ItemCount: 0 + }); + + expect(_.find(desc.GlobalSecondaryIndexes, { IndexName: 'GlobalAgeWinsIndex' })).to.eql({ + IndexName: 'GlobalAgeWinsIndex', + KeySchema:[ + { AttributeName: 'age', KeyType: 'HASH' }, + { AttributeName: 'wins', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + ProvisionedThroughput : { ReadCapacityUnits : 1, WriteCapacityUnits : 1}, + IndexSizeBytes: 0, + IndexStatus : 'ACTIVE', + ItemCount: 0 + }); + + expect(desc.LocalSecondaryIndexes).to.have.length(2); + + expect(_.find(desc.LocalSecondaryIndexes, { IndexName: 'NameNickIndex' })).to.eql({ + IndexName: 'NameNickIndex', + KeySchema:[ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'nick', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + IndexSizeBytes: 0, + ItemCount: 0 + }); + + expect(_.find(desc.LocalSecondaryIndexes, { IndexName: 'NameWinsIndex' })).to.eql({ + IndexName: 'NameWinsIndex', + KeySchema:[ + { AttributeName: 'name', KeyType: 'HASH' }, + { AttributeName: 'wins', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + IndexSizeBytes: 0, + ItemCount: 0 + }); + + return Model.deleteTable(done); + }); + }); +}); + diff --git a/test/integration/integration-test.js b/test/integration/integration-test.js new file mode 100644 index 0000000..7a32977 --- /dev/null +++ b/test/integration/integration-test.js @@ -0,0 +1,862 @@ +'use strict'; + +var vogels = require('../../index'), + chai = require('chai'), + expect = chai.expect, + async = require('async'), + _ = require('lodash'), + helper = require('../test-helper'), + uuid = require('node-uuid'), + Joi = require('joi'); + +chai.should(); + +var User, Tweet, Movie, DynamicKeyModel; // models +var internals = {}; + +internals.userId = function (n) { + return 'userid-' + n; +}; + +internals.loadSeedData = function (callback) { + callback = callback || _.noop; + + async.parallel([ + function (callback) { + async.times(15, function(n, next) { + var roles = ['user']; + if(n % 3 === 0) { + roles = ['admin', 'editor']; + } else if (n %5 === 0) { + roles = ['qa', 'dev']; + } + + User.create({id : internals.userId(n), email: 'test' + n + '@example.com', name : 'Test ' + n %3, age: n +10, roles : roles}, next); + }, callback); + }, + function (callback) { + async.times(15 * 5, function(n, next) { + var userId = internals.userId( n %5); + var p = {UserId : userId, content: 'I love tweeting, in fact Ive tweeted ' + n + ' times', num : n}; + if(n %3 === 0 ) { + p.tag = '#test'; + } + + return Tweet.create(p, next); + }, callback); + }, + function (callback) { + async.times(10, function(n, next) { + var director = { firstName : 'Steven', lastName : 'Spielberg the ' + n, titles : ['Producer', 'Writer', 'Director']}; + var actors = [ + { firstName : 'Tom', lastName : 'Hanks', titles : ['Producer', 'Actor', 'Soundtrack']} + ]; + + var tags = ['tag ' + n]; + + if(n %3 === 0) { + actors.push({ firstName : 'Rex', lastName : 'Ryan', titles : ['Actor', 'Head Coach']}); + tags.push('Action'); + } + + if(n %5 === 0) { + actors.push({ firstName : 'Tom', lastName : 'Coughlin', titles : ['Writer', 'Head Coach']}); + tags.push('Comedy'); + } + + Movie.create({title : 'Movie ' + n, releaseYear : 2001 + n, actors : actors, director : director, tags: tags}, next); + }, callback); + }, + ], callback); +}; + +describe('Vogels Integration Tests', function() { + this.timeout(0); + + before(function (done) { + vogels.dynamoDriver(helper.realDynamoDB()); + + User = vogels.define('vogels-int-test-user', { + hashKey : 'id', + schema : { + id : Joi.string().required().default(uuid.v4), + email : Joi.string().required(), + name : Joi.string(), + age : Joi.number().min(10), + roles : vogels.types.stringSet().default(['user']), + acceptedTerms : Joi.boolean().default(false), + things : Joi.array(), + settings : { + nickname : Joi.string(), + notify : Joi.boolean().default(true), + version : Joi.number() + } + + } + }); + + Tweet = vogels.define('vogels-int-test-tweet', { + hashKey : 'UserId', + rangeKey : 'TweetID', + schema : { + UserId : Joi.string(), + TweetID : vogels.types.uuid(), + content : Joi.string(), + num : Joi.number(), + tag : Joi.string(), + PublishedDateTime : Joi.date().default(Date.now) + }, + indexes : [ + { hashKey : 'UserId', rangeKey : 'PublishedDateTime', type : 'local', name : 'PublishedDateTimeIndex'} + ] + }); + + Movie = vogels.define('vogels-int-test-movie', { + hashKey : 'title', + timestamps : true, + schema : { + title : Joi.string(), + description : Joi.string(), + releaseYear : Joi.number(), + tags : vogels.types.stringSet(), + director : Joi.object().keys({ + firstName : Joi.string(), + lastName : Joi.string(), + titles : Joi.array() + }), + actors : Joi.array().includes(Joi.object().keys({ + firstName : Joi.string(), + lastName : Joi.string(), + titles : Joi.array() + })) + } + }); + + DynamicKeyModel = vogels.define('vogels-int-test-dyn-key', { + hashKey : 'id', + schema : Joi.object().keys({ + id : Joi.string() + }).unknown() + }); + + async.series([ + async.apply(vogels.createTables.bind(vogels)), + function (callback) { + var items = [{fiz : 3, buz : 5, fizbuz: 35}]; + User.create({id : '123456789', email : 'some@user.com', age: 30, settings : {nickname : 'thedude'}, things : items}, callback); + }, + internals.loadSeedData + ], done); + }); + + describe('#create', function () { + it('should create item with hash key', function(done) { + User.create({ + email : 'foo@bar.com', + age : 18, + roles : ['user', 'admin'], + acceptedTerms : true, + settings : { + nickname : 'fooos', + version : 2 + } + }, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['id', 'email', 'age', 'roles', 'acceptedTerms', 'settings']); + return done(); + }); + }); + + it('should return condition exception when using ConditionExpression', function(done) { + var item = { email : 'test123@test.com', age : 33, roles : ['user'] }; + + User.create(item, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get('email')).to.eql('test123@test.com'); + + var params = {}; + params.ConditionExpression = '#i <> :x'; + params.ExpressionAttributeNames = {'#i' : 'id'}; + params.ExpressionAttributeValues = {':x' : acc.get('id')}; + + var item2 = _.merge(item, {id : acc.get('id')}); + User.create(item2, params, function (error, acc) { + expect(error).to.exist; + expect(error.code).to.eql('ConditionalCheckFailedException'); + expect(acc).to.not.exist; + + return done(); + }); + }); + }); + + it('should create item with dynamic keys', function(done) { + DynamicKeyModel.create({ + id : 'rand-1', + name : 'Foo Bar', + children : ['sam', 'steve', 'sarah', 'sally'], + settings : { nickname : 'Tester', info : { color : 'green', age : 19 } } + }, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['id', 'name', 'children', 'settings']); + return done(); + }); + }); + + }); + + describe('#get', function () { + it('should get item by hash key', function(done) { + User.get({ id : '123456789'}, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['id', 'email', 'age', 'roles', 'acceptedTerms', 'settings', 'things']); + return done(); + }); + }); + + it('should get return selected attributes AttributesToGet param', function(done) { + User.get({ id : '123456789'},{AttributesToGet : ['email', 'age']}, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['email', 'age']); + return done(); + }); + }); + + it('should get return selected attributes using ProjectionExpression param', function(done) { + User.get({ id : '123456789'},{ProjectionExpression : 'email, age, settings.nickname'}, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['email', 'age', 'settings']); + expect(acc.get('settings').nickname).to.exist; + return done(); + }); + }); + + + }); + + describe('#update', function () { + it('should update item appended role', function(done) { + User.update({ + id : '123456789', + roles : {$add : 'tester'} + }, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['id', 'email', 'age', 'roles', 'acceptedTerms', 'settings', 'things']); + expect(acc.get('roles').sort()).to.eql(['tester', 'user']); + + return done(); + }); + }); + + it('should remove settings attribute from user record', function(done) { + User.update({ id : '123456789', settings : null}, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + + expect(acc.get()).to.have.keys(['id', 'email', 'age', 'roles', 'acceptedTerms', 'things']); + return done(); + }); + }); + + it('should update User using updateExpression', function(done) { + var params = {}; + params.UpdateExpression = 'ADD #a :x SET things[0].buz = :y'; + params.ConditionExpression = '#a = :current'; + params.ExpressionAttributeNames = {'#a' : 'age'}; + params.ExpressionAttributeValues = {':x' : 1, ':y' : 22, ':current' : 30}; + + User.update({ id : '123456789'}, params, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get('age')).to.equal(31); + expect(acc.get('things')).to.eql([{fiz : 3, buz : 22, fizbuz: 35}]); + return done(); + }); + }); + + it('should update Movie using updateExpressions', function (done) { + var params = {}; + params.UpdateExpression = 'SET #year = #year + :inc, #dir.titles = list_append(#dir.titles, :title), #act[0].firstName = :firstName ADD tags :tag'; + params.ConditionExpression = '#year = :current'; + params.ExpressionAttributeNames = { + '#year' : 'releaseYear', + '#dir' : 'director', + '#act' : 'actors' + }; + + params.ExpressionAttributeValues = { + ':inc' : 1, + ':current' : 2001, + ':title' : ['The Man'], + ':firstName' : 'Rob', + ':tag' : vogels.Set(['Sports', 'Horror'], 'S') + }; + + Movie.update({title : 'Movie 0', description : 'This is a description'}, params, function (err, mov) { + expect(err).to.not.exist(); + + expect(mov.get('description')).to.eql('This is a description'); + expect(mov.get('releaseYear')).to.eql(2002); + expect(mov.get('updatedAt')).to.exist; + return done(); + }); + }); + + it('should update item with dynamic keys', function(done) { + DynamicKeyModel.update({ + id : 'rand-5', + color : 'green', + settings : { email : 'dynupdate@test.com'} + }, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get()).to.have.keys(['id', 'settings', 'color']); + + expect(acc.get()).to.eql( { + id : 'rand-5', + color : 'green', + settings : { email : 'dynupdate@test.com'} + }); + + return done(); + }); + }); + + }); + + describe('#getItems', function () { + it('should return 3 items', function(done) { + User.getItems(['userid-1', 'userid-2', 'userid-3'], function (err, accounts) { + expect(err).to.not.exist; + expect(accounts).to.have.length(3); + return done(); + }); + }); + + it('should return 2 items with only selected attributes', function(done) { + var opts = {AttributesToGet : ['email', 'age']}; + + User.getItems(['userid-1', 'userid-2'], opts, function (err, accounts) { + expect(err).to.not.exist; + expect(accounts).to.have.length(2); + _.each(accounts, function (acc) { + expect(acc.get()).to.have.keys(['email', 'age']); + }); + + return done(); + }); + }); + }); + + describe('#query', function () { + it('should return users tweets', function(done) { + Tweet.query('userid-1').exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (t) { + expect(t.get('UserId')).to.eql('userid-1'); + }); + + return done(); + }); + }); + + it('should return tweets using secondaryIndex', function(done) { + Tweet.query('userid-1') + .usingIndex('PublishedDateTimeIndex') + .consistentRead(true) + .descending() + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + var prev; + _.each(data.Items, function (t) { + expect(t.get('UserId')).to.eql('userid-1'); + + var published = t.get('PublishedDateTime'); + + if(prev) { + expect(published).to.be.below(prev); + } + + prev = published; + }); + + return done(); + }); + }); + + it('should return tweets using secondaryIndex and date object', function(done) { + var oneMinAgo = new Date(new Date().getTime() - 60*1000); + + Tweet.query('userid-1') + .usingIndex('PublishedDateTimeIndex') + .where('PublishedDateTime').gt(oneMinAgo) + .descending() + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + var prev; + _.each(data.Items, function (t) { + expect(t.get('UserId')).to.eql('userid-1'); + + var published = t.get('PublishedDateTime'); + + if(prev) { + expect(published).to.be.below(prev); + } + + prev = published; + }); + + return done(); + }); + }); + + it('should return tweets that match filters', function(done) { + Tweet.query('userid-1') + .filter('num').between(4, 8) + .filter('tag').exists() + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (t) { + expect(t.get('UserId')).to.eql('userid-1'); + expect(t.get('num')).to.be.above(3); + expect(t.get('num')).to.be.below(9); + expect(t.get('tag')).to.exist(); + }); + + return done(); + }); + }); + + + it('should return tweets that match expression filters', function(done) { + Tweet.query('userid-1') + .filterExpression('#num BETWEEN :low AND :high AND attribute_exists(#tag)') + .expressionAttributeValues({ ':low' : 4, ':high' : 8}) + .expressionAttributeNames({ '#num' : 'num', '#tag' : 'tag'}) + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (t) { + expect(t.get('UserId')).to.eql('userid-1'); + expect(t.get('num')).to.be.above(3); + expect(t.get('num')).to.be.below(9); + expect(t.get('tag')).to.exist(); + }); + + return done(); + }); + }); + + it('should return tweets with projection expression', function(done) { + Tweet.query('userid-1') + .projectionExpression('#con, UserId') + .expressionAttributeNames({ '#con' : 'content'}) + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (t) { + expect(t.get()).to.have.keys(['content', 'UserId']); + }); + + return done(); + }); + }); + + }); + + + describe('#scan', function () { + it('should return all users', function(done) { + User.scan().loadAll().exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + return done(); + }); + + }); + + it('should return 10 users', function(done) { + User.scan().limit(10).exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length(10); + + return done(); + }); + }); + + it('should return users older than 18', function(done) { + User.scan() + .where('age').gt(18) + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (u) { + expect(u.get('age')).to.be.above(18); + }); + + return done(); + }); + }); + + it('should return users matching multiple filters', function(done) { + User.scan() + .where('age').between(18, 22) + .where('email').beginsWith('test1') + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (u) { + expect(u.get('age')).to.be.within(18, 22); + expect(u.get('email')).to.match(/^test1.*/); + }); + + return done(); + }); + }); + + it('should return users contains admin role', function(done) { + User.scan() + .where('roles').contains('admin') + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (u) { + expect(u.get('roles')).to.include('admin'); + }); + + return done(); + }); + }); + + it('should return users using stream interface', function(done) { + var stream = User.scan().exec(); + + var called = false; + stream.on('readable', function () { + called = true; + expect(stream.read().Items).to.have.length.above(0); + }); + + stream.on('end', function () { + expect(called).to.be.true; + return done(); + }); + }); + + it('should return users that match expression filters', function(done) { + User.scan() + .filterExpression('#age BETWEEN :low AND :high AND begins_with(#email, :e)') + .expressionAttributeValues({ ':low' : 18, ':high' : 22, ':e' : 'test1'}) + .expressionAttributeNames({ '#age' : 'age', '#email' : 'email'}) + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (u) { + expect(u.get('age')).to.be.within(18, 22); + expect(u.get('email')).to.match(/^test1.*/); + }); + + return done(); + }); + }); + + it('should return users with projection expression', function(done) { + User.scan() + .projectionExpression('age, email, #roles') + .expressionAttributeNames({ '#roles' : 'roles'}) + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (u) { + expect(u.get()).to.have.keys(['age', 'email', 'roles']); + }); + + return done(); + }); + }); + + it('should load all users with limit', function(done) { + User.scan().loadAll().limit(2).exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + return done(); + }); + }); + + it('should return users using stream interface and limit', function(done) { + var stream = User.scan().loadAll().limit(2).exec(); + + var called = false; + stream.on('readable', function () { + called = true; + var items = stream.read().Items; + expect(items).to.have.length.within(0, 2); + }); + + stream.on('end', function () { + expect(called).to.be.true; + return done(); + }); + }); + + }); + + describe('#parallelScan', function () { + it('should return all users', function(done) { + User.parallelScan(4).exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + return done(); + }); + + }); + + it('should return users older than 18', function(done) { + User.parallelScan(4) + .where('age').gt(18) + .exec(function (err, data) { + expect(err).to.not.exist; + expect(data.Items).to.have.length.above(0); + + _.each(data.Items, function (u) { + expect(u.get('age')).to.be.above(18); + }); + + return done(); + }); + }); + + it('should return users using stream interface', function(done) { + var stream = User.parallelScan(4).exec(); + + var called = false; + stream.on('readable', function () { + called = true; + expect(stream.read().Items).to.have.length.above(0); + }); + + stream.on('end', function () { + expect(called).to.be.true; + return done(); + }); + }); + + }); + + + describe('timestamps', function () { + var Model; + + before(function (done) { + Model = vogels.define('vogels-int-test-timestamp', { + hashKey : 'id', + timestamps : true, + schema : { + id : Joi.string() + } + }); + + return vogels.createTables(done); + }); + + it('should add createdAt param', function (done) { + Model.create({id : 'test-1'}, function (err) { + expect(err).to.not.exist; + + Model.get('test-1', function (err2, data) { + expect(err2).to.not.exist; + + expect(data.get('id')).to.eql('test-1'); + expect(data.get('createdAt')).to.exist; + + return done(); + }); + + }); + }); + + it('should add updatedAt param', function (done) { + Model.update({id : 'test-2'}, function (err) { + expect(err).to.not.exist; + + Model.get('test-2', function (err2, data) { + expect(err2).to.not.exist; + + expect(data.get('id')).to.eql('test-2'); + expect(data.get('updatedAt')).to.exist; + + return done(); + }); + + }); + }); + }); + + describe('#destroy', function () { + var userId; + beforeEach(function (done) { + User.create({email : 'destroy@test.com', age : 20, roles : ['tester']}, function (err, acc) { + expect(err).to.not.exist; + userId = acc.get('id'); + + return done(); + }); + }); + + it('should destroy item with hash key', function(done) { + User.destroy({ id : userId }, function (err) { + expect(err).to.not.exist; + return done(); + }); + }); + + it('should destroy item and return old values', function(done) { + User.destroy({ id : userId }, {ReturnValues : 'ALL_OLD'}, function (err, acc) { + expect(err).to.not.exist; + expect(acc).to.exist; + expect(acc.get('email')).to.eql('destroy@test.com'); + return done(); + }); + }); + + it('should return condition exception when using ConditionExpression', function(done) { + var params = {}; + params.ConditionExpression = '#i = :x'; + params.ExpressionAttributeNames = {'#i' : 'id'}; + params.ExpressionAttributeValues = {':x' : 'dontexist'}; + + User.destroy({id : 'dontexist'}, params, function (err, acc) { + expect(err).to.exist; + expect(err.code).to.eql('ConditionalCheckFailedException'); + expect(acc).to.not.exist; + + return done(); + }); + }); + }); + + + describe('model methods', function () { + + it('#save with passed in attributes', function (done) { + var t = new Tweet({ + UserId : 'tester-1', + content : 'save test tweet', + tag : 'test' + }); + + t.save(function (err) { + expect(err).to.not.exist; + return done(); + }); + }); + + it('#save without passed in attributes', function (done) { + var t = new Tweet(); + + var attrs = { UserId : 'tester-1', content : 'save test tweet', tag : 'test' }; + t.set(attrs); + + t.save(function (err) { + expect(err).to.not.exist; + return done(); + }); + }); + + it('#save without callback', function (done) { + var t = new Tweet({ + UserId : 'tester-1', + content : 'save test tweet', + tag : 'test' + }); + + t.save(); + + return done(); + }); + + it('#update with callback', function (done) { + Tweet.create({UserId : 'tester-2', content : 'update test tweet'}, function (err, tweet) { + expect(err).to.not.exist; + + tweet.set({tag : 'update'}); + + tweet.update(function (err) { + expect(err).to.not.exist; + expect(tweet.get('tag')).to.eql('update'); + return done(); + }); + + }); + }); + + it('#update without callback', function (done) { + Tweet.create({UserId : 'tester-2', content : 'update test tweet'}, function (err, tweet) { + expect(err).to.not.exist; + + tweet.set({tag : 'update'}); + + tweet.update(); + + return done(); + }); + }); + + + it('#destroy with callback', function (done) { + Tweet.create({UserId : 'tester-2', content : 'update test tweet'}, function (err, tweet) { + expect(err).to.not.exist; + + tweet.destroy(function (err) { + expect(err).to.not.exist; + return done(); + }); + }); + }); + + it('#destroy without callback', function (done) { + Tweet.create({UserId : 'tester-2', content : 'update test tweet'}, function (err, tweet) { + expect(err).to.not.exist; + + tweet.destroy(); + + return done(); + }); + }); + + it('#toJSON', function (done) { + Tweet.create({UserId : 'tester-2', content : 'update test tweet'}, function (err, tweet) { + expect(err).to.not.exist; + + expect(tweet.toJSON()).to.have.keys(['UserId', 'content', 'TweetID', 'PublishedDateTime']); + return done(); + }); + }); + }); + +}); diff --git a/test/item-test.js b/test/item-test.js index f7f73a7..54dec65 100644 --- a/test/item-test.js +++ b/test/item-test.js @@ -4,21 +4,101 @@ var Item = require('../lib/item'), Table = require('../lib/table'), Schema = require('../lib/schema'), chai = require('chai'), - helper = require('./test-helper'); + expect = chai.expect, + helper = require('./test-helper'), + serializer = require('../lib/serializer'), + Joi = require('joi'); chai.should(); describe('item', function() { - it('JSON.stringify should only serialize attrs', function() { - var schema = new Schema(); - schema.Number('num'); - schema.String('name'); + var table; + + beforeEach(function () { + var config = { + hashKey: 'num', + schema : { + num : Joi.number(), + name : Joi.string() + } + }; + + var schema = new Schema(config); + + table = new Table('mockTable', schema, serializer, helper.mockDynamoDB()); + }); - var table = new Table('mockTable', schema, helper.mockSerializer(), helper.mockDynamoDB()); + it('JSON.stringify should only serialize attrs', function() { var attrs = {num: 1, name: 'foo'}; var item = new Item(attrs, table); var stringified = JSON.stringify(item); stringified.should.equal(JSON.stringify(attrs)); }); + + describe('#save', function () { + + it('should return error', function (done) { + table.docClient.putItem.yields(new Error('fail')); + + var attrs = {num: 1, name: 'foo'}; + var item = new Item(attrs, table); + + item.save(function (err, data) { + expect(err).to.exist; + expect(data).to.not.exist; + + return done(); + }); + + }); + + }); + + describe('#update', function () { + it('should return item', function (done) { + table.docClient.updateItem.yields(null, {Attributes : {num : 1, name : 'foo'}}); + + var attrs = {num: 1, name: 'foo'}; + var item = new Item(attrs, table); + + item.update(function (err, data) { + expect(err).to.not.exist; + expect(data.get()).to.eql({ num : 1, name : 'foo'}); + + return done(); + }); + }); + + + it('should return error', function (done) { + table.docClient.updateItem.yields(new Error('fail')); + + var attrs = {num: 1, name: 'foo'}; + var item = new Item(attrs, table); + + item.update(function (err, data) { + expect(err).to.exist; + expect(data).to.not.exist; + + return done(); + }); + + }); + + it('should return null', function (done) { + table.docClient.updateItem.yields(null, {}); + + var attrs = {num: 1, name: 'foo'}; + var item = new Item(attrs, table); + + item.update(function (err, data) { + expect(err).to.not.exist; + expect(data).to.not.exist; + + return done(); + }); + }); + + }); }); diff --git a/test/parallel-test.js b/test/parallel-test.js new file mode 100644 index 0000000..67e63da --- /dev/null +++ b/test/parallel-test.js @@ -0,0 +1,65 @@ +'use strict'; + +var Table = require('../lib/table'), + ParallelScan = require('../lib/parallelScan'), + Schema = require('../lib/schema'), + chai = require('chai'), + expect = chai.expect, + assert = require('assert'), + helper = require('./test-helper'), + serializer = require('../lib/serializer'), + Joi = require('joi'); + +chai.should(); + +describe('ParallelScan', function() { + var table; + + beforeEach(function () { + var config = { + hashKey: 'num', + schema : { + num : Joi.number(), + name : Joi.string() + } + }; + + var schema = new Schema(config); + + table = new Table('mockTable', schema, serializer, helper.mockDynamoDB()); + }); + + it('should return error', function (done) { + var scan = new ParallelScan(table, serializer, 4); + + table.docClient.scan.yields(new Error('fail')); + + scan.exec(function (err, data) { + expect(err).to.exist; + expect(data).to.not.exist; + + return done(); + }); + + }); + + it('should stream error', function (done) { + var scan = new ParallelScan(table, serializer, 4); + + table.docClient.scan.yields(new Error('fail')); + + var stream = scan.exec(); + + stream.on('error', function (err) { + console.log('test here'); + expect(err).to.exist; + return done(); + }); + + stream.on('readable', function () { + assert(false, 'readable should not be called'); + }); + + }); + +}); diff --git a/test/query-test.js b/test/query-test.js index 3662555..2ccd4d9 100644 --- a/test/query-test.js +++ b/test/query-test.js @@ -2,27 +2,43 @@ var helper = require('./test-helper'), Schema = require('../lib/schema'), - Query = require('../lib//query'); + Query = require('../lib//query'), + Serializer = require('../lib/serializer'), + Table = require('../lib/table'), + _ = require('lodash'), + chai = require('chai'), + expect = chai.expect, + assert = require('assert'), + sinon = require('sinon'), + Joi = require('joi'); + +chai.should(); describe('Query', function () { - var schema, - serializer, + var serializer, table; beforeEach(function () { - schema = new Schema(); serializer = helper.mockSerializer(), table = helper.mockTable(); table.config = {name : 'accounts'}; - table.schema = schema; + table.docClient = helper.mockDocClient(); }); describe('#exec', function () { it('should run query against table', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; + + table.schema = new Schema(config); table.runQuery.yields(null, {}); serializer.serializeItem.returns({name: {S: 'tim'}}); @@ -33,39 +49,235 @@ describe('Query', function () { }); }); + it('should return error', function (done) { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; + + var s = new Schema(config); + var t = new Table('accounts', s, Serializer, helper.mockDocClient()); + + t.docClient.query.yields(new Error('Fail')); + + new Query('tim', t, Serializer).exec(function (err, results) { + expect(err).to.exist; + expect(results).to.not.exist; + done(); + }); + }); + + it('should stream error', function (done) { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; + + var s = new Schema(config); + + var t = new Table('accounts', s, Serializer, helper.mockDocClient()); + + t.docClient.query.yields(new Error('Fail')); + + var stream = new Query('tim', t, Serializer).exec(); + + stream.on('error', function (err) { + expect(err).to.exist; + return done(); + }); + + stream.on('readable', function () { + assert(false, 'readable should not be called'); + }); + + }); + + it('should stream data after handling retryable error', function (done) { + var clock = sinon.useFakeTimers(); + + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; + + var s = new Schema(config); + + var t = new Table('accounts', s, Serializer, helper.mockDocClient()); + + var err = new Error('RetryableException'); + err.retryable = true; + + t.docClient.query + .onCall(0).yields(err) + .onCall(1).yields(null, {Items : [ { name : 'Tim Tester', email : 'test@test.com'} ]}); + + var stream = new Query('tim', t, Serializer).exec(); + + var called = false; + + stream.on('readable', function () { + called = true; + expect(stream.read().Items).to.have.length.above(0); + }); + + stream.on('end', function () { + expect(called).to.be.true; + + clock.restore(); + return done(); + }); + + clock.tick(2000); + }); }); describe('#limit', function () { + beforeEach(function () { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; - it('should set the limit', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + table.schema = new Schema(config); + }); + it('should set the limit', function () { var query = new Query('tim', table, serializer).limit(10); - query.request.Limit.should.equal(10); }); + it('should throw when limit is zero', function () { + var query = new Query('tim', table, serializer); + + expect(function () { + query.limit(0); + }).to.throw('Limit must be greater than 0'); + }); + + }); + + describe('#filterExpression', function () { + + it('should set filter expression', function () { + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var query = new Query('tim', table, serializer).filterExpression('Postedby = :val'); + + query.request.FilterExpression.should.equal('Postedby = :val'); + }); + }); + + describe('#expressionAttributeValues', function () { + + it('should set expression attribute values', function () { + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var query = new Query('tim', table, serializer).expressionAttributeValues({ ':val' : 'test'}); + + query.request.ExpressionAttributeValues.should.eql({ ':val' : 'test'}); + }); + }); + + describe('#expressionAttributeNames', function () { + + it('should set expression attribute names', function () { + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var query = new Query('tim', table, serializer).expressionAttributeNames({ '#name' : 'name'}); + + query.request.ExpressionAttributeNames.should.eql({ '#name' : 'name'}); + }); + }); + + describe('#projectionExpression', function () { + + it('should set projection expression', function () { + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; + + table.schema = new Schema(config); + + var query = new Query('tim', table, serializer).projectionExpression( '#name, #email'); + + query.request.ProjectionExpression.should.eql('#name, #email'); + }); }); describe('#usingIndex', function () { it('should set the index name to use', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; + + table.schema = new Schema(config); - var query = new Query('tim', table, serializer).usingIndex('created'); + var query = new Query('tim', table, serializer).usingIndex('CreatedIndex'); - query.request.IndexName.should.equal('created'); + query.request.IndexName.should.equal('CreatedIndex'); }); it('should create key condition for global index hash key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age'); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + }, + indexes : [{ hashKey : 'age', type : 'global', name : 'UserAgeIndex'}] + }; - schema.globalIndex('UserAgeIndex', {hashKey: 'age'}); + table.schema = new Schema(config); serializer.serializeItem.returns({age: {N: '18'}}); @@ -73,26 +285,40 @@ describe('Query', function () { query.buildRequest(); query.request.IndexName.should.equal('UserAgeIndex'); - query.request.KeyConditions.age.should.eql({AttributeValueList: [{N: '18'}], ComparisonOperator: 'EQ'}); + query.request.KeyConditions.should.have.length(1); + + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{N: '18'}], ComparisonOperator: 'EQ'}); }); }); describe('#consistentRead', function () { + beforeEach(function () { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; - it('should set Consistent Read to true', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + table.schema = new Schema(config); + }); + it('should set Consistent Read to true', function () { var query = new Query('tim', table, serializer).consistentRead(true); query.request.ConsistentRead.should.be.true; }); - it('should set Consistent Read to false', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + it('should set Consistent Read to true when passing no args', function () { + var query = new Query('tim', table, serializer).consistentRead(); + query.request.ConsistentRead.should.be.true; + }); + it('should set Consistent Read to false', function () { var query = new Query('tim', table, serializer).consistentRead(false); query.request.ConsistentRead.should.be.false; }); @@ -100,21 +326,27 @@ describe('Query', function () { }); describe('#attributes', function () { + beforeEach(function () { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; - it('should set array attributes to get', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + table.schema = new Schema(config); + }); + it('should set array attributes to get', function () { var query = new Query('tim', table, serializer).attributes(['created']); query.request.AttributesToGet.should.eql(['created']); }); it('should set single attribute to get', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var query = new Query('tim', table, serializer).attributes('email'); query.request.AttributesToGet.should.eql(['email']); }); @@ -122,21 +354,27 @@ describe('Query', function () { }); describe('#order', function () { + beforeEach(function () { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; - it('should set scan index forward to true', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + table.schema = new Schema(config); + }); + it('should set scan index forward to true', function () { var query = new Query('tim', table, serializer).ascending(); query.request.ScanIndexForward.should.be.true; }); it('should set scan index forward to false', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var query = new Query('tim', table, serializer).descending(); query.request.ScanIndexForward.should.be.false; }); @@ -144,12 +382,22 @@ describe('Query', function () { }); describe('#startKey', function () { + beforeEach(function () { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; - it('should set start Key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + table.schema = new Schema(config); + }); + it('should set start Key', function () { var key = {name: {S: 'tim'}, email : {S: 'foo@example.com'}}; serializer.buildKey.returns(key); @@ -162,9 +410,18 @@ describe('Query', function () { describe('#select', function () { it('should set select Key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; + + table.schema = new Schema(config); var query = new Query('tim', table, serializer).select('COUNT'); @@ -173,21 +430,28 @@ describe('Query', function () { }); describe('#ReturnConsumedCapacity', function () { + beforeEach(function () { + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; - it('should set return consumed capacity Key to passed in value', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + table.schema = new Schema(config); + }); + it('should set return consumed capacity Key to passed in value', function () { var query = new Query('tim', table, serializer).returnConsumedCapacity('TOTAL'); + query.request.ReturnConsumedCapacity.should.eql('TOTAL'); }); it('should set return consumed capacity Key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var query = new Query('tim', table, serializer).returnConsumedCapacity(); query.request.ReturnConsumedCapacity.should.eql('TOTAL'); @@ -198,66 +462,71 @@ describe('Query', function () { var query; beforeEach(function () { - query = new Query('tim', table, serializer); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); + table.schema = new Schema(config); + query = new Query('tim', table, serializer); }); it('should have equals clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - query = query.where('email').equals('foo@example.com'); - query.request.KeyConditions.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'EQ'}); + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'EQ'}); }); it('should have less than or equal clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - query = query.where('email').lte('foo@example.com'); - query.request.KeyConditions.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LE'}); + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LE'}); }); it('should have less than clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - query = query.where('email').lt('foo@example.com'); - query.request.KeyConditions.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LT'}); + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LT'}); }); it('should have greater than or equal clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - query = query.where('email').gte('foo@example.com'); - query.request.KeyConditions.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GE'}); + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GE'}); }); it('should have greater than clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - query = query.where('email').gt('foo@example.com'); - query.request.KeyConditions.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GT'}); + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GT'}); }); it('should have begins with clause', function() { - serializer.serializeItem.returns({email: {S: 'foo'}}); - query = query.where('email').beginsWith('foo'); - query.request.KeyConditions.email.should.eql({AttributeValueList: [{S: 'foo'}], ComparisonOperator: 'BEGINS_WITH'}); + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql({AttributeValueList: [{S: 'foo'}], ComparisonOperator: 'BEGINS_WITH'}); }); it('should have between clause', function() { - serializer.serializeItem.withArgs(schema, {email: 'bob@bob.com'}).returns({email: {S: 'bob@bob.com'}}); - serializer.serializeItem.withArgs(schema, {email: 'foo@foo.com'}).returns({email: {S: 'foo@foo.com'}}); - - query = query.where('email').between(['bob@bob.com', 'foo@foo.com']); + query = query.where('email').between('bob@bob.com', 'foo@foo.com'); var expect = { AttributeValueList: [ @@ -267,7 +536,11 @@ describe('Query', function () { ComparisonOperator: 'BETWEEN' }; - query.request.KeyConditions.email.should.eql(expect); + //query.request.KeyConditions.email.should.eql(expect); + + query.request.KeyConditions.should.have.length(1); + var cond = _.first(query.request.KeyConditions); + cond.format().should.eql(expect); }); }); @@ -276,39 +549,49 @@ describe('Query', function () { var query; beforeEach(function () { - query = new Query('tim', table, serializer); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date(), + age : Joi.number() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; + + table.schema = new Schema(config); - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - schema.Number('age'); + query = new Query('tim', table, serializer); }); it('should have equals clause', function() { - serializer.serializeItem.withArgs(schema, {age: 5}).returns({age: {N: '5'}}); - query = query.filter('age').equals(5); - query.request.QueryFilter.age.should.eql({AttributeValueList: [{N: '5'}], ComparisonOperator: 'EQ'}); + query.request.QueryFilter.should.have.length(1); + var cond = _.first(query.request.QueryFilter); + cond.format().should.eql({AttributeValueList: [{N: '5'}], ComparisonOperator: 'EQ'}); }); it('should have exists clause', function() { query = query.filter('age').exists(); - query.request.QueryFilter.age.should.eql({ComparisonOperator: 'NOT_NULL'}); + query.request.QueryFilter.should.have.length(1); + var cond = _.first(query.request.QueryFilter); + cond.format().should.eql({ComparisonOperator: 'NOT_NULL'}); }); it('should have not exists clause', function() { query = query.filter('age').exists(false); - query.request.QueryFilter.age.should.eql({ComparisonOperator: 'NULL'}); + query.request.QueryFilter.should.have.length(1); + var cond = _.first(query.request.QueryFilter); + cond.format().should.eql({ComparisonOperator: 'NULL'}); }); it('should have between clause', function() { - serializer.serializeItem.withArgs(schema, {age: 5}).returns({age: {N: '5'}}); - serializer.serializeItem.withArgs(schema, {age: 7}).returns({age: {N: '7'}}); - - query = query.filter('age').between([5, 7]); + query = query.filter('age').between(5, 7); var expected = { AttributeValueList: [ @@ -318,13 +601,14 @@ describe('Query', function () { ComparisonOperator: 'BETWEEN' }; - query.request.QueryFilter.age.should.eql(expected); + query.request.QueryFilter.should.have.length(1); + var cond = _.first(query.request.QueryFilter); + cond.format().should.eql(expected); }); - it('should have IN clause', function() { - serializer.serializeItem.withArgs(schema, {age: 5}).returns({age: {N: '5'}}); - serializer.serializeItem.withArgs(schema, {age: 7}).returns({age: {N: '7'}}); - + it.skip('should have IN clause', function() { + // TODO ther is a bug in the dynamodb-doc lib + // that needs to get fixed before this test can pass query = query.filter('age').in([5, 7]); var expected = { @@ -335,7 +619,9 @@ describe('Query', function () { ComparisonOperator: 'IN' }; - query.request.QueryFilter.age.should.eql(expected); + query.request.QueryFilter.should.have.length(1); + var cond = _.first(query.request.QueryFilter); + cond.format().should.eql(expected); }); }); @@ -343,12 +629,20 @@ describe('Query', function () { describe('#loadAll', function () { it('should set load all option to true', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + } + }; + + table.schema = new Schema(config); var query = new Query('tim', table, serializer).loadAll(); - query.options.loadAll = true; + query.options.loadAll.should.be.true; }); }); diff --git a/test/scan-test.js b/test/scan-test.js index 7551f32..7b5dffd 100644 --- a/test/scan-test.js +++ b/test/scan-test.js @@ -2,7 +2,27 @@ var helper = require('./test-helper'), Schema = require('../lib/schema'), - Scan = require('../lib/scan'); + Scan = require('../lib/scan'), + _ = require('lodash'), + chai = require('chai'), + expect = chai.expect, + Joi = require('joi'); + +chai.should(); + +var internals = {}; + +internals.assertScanFilter = function (scan, expected) { + var conds = _.map(scan.request.ScanFilter, function (c) { + return c.format(); + }); + + if(!_.isArray(expected)) { + expected = [expected]; + } + + conds.should.eql(expected); +}; describe('Scan', function () { var schema, @@ -10,7 +30,6 @@ describe('Scan', function () { table; beforeEach(function () { - schema = new Schema(); serializer = helper.mockSerializer(), table = helper.mockTable(); @@ -18,15 +37,26 @@ describe('Scan', function () { return 'accounts'; }; + table.docClient = helper.mockDocClient(); + + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; + + schema = new Schema(config); table.schema = schema; }); describe('#exec', function () { it('should call run scan on table', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - table.runScan.yields(null, {ConsumedCapacity: {CapacityUnits : 5, TableName: 'accounts'}, Count: 10, ScannedCount: 12}); serializer.serializeItem.returns({name: {S: 'tim'}}); @@ -40,9 +70,6 @@ describe('Scan', function () { }); it('should return LastEvaluatedKey', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email'); - table.runScan.yields(null, {LastEvaluatedKey: {name : 'tim'}, Count: 10, ScannedCount: 12}); serializer.serializeItem.returns({name: {S: 'tim'}}); @@ -56,37 +83,64 @@ describe('Scan', function () { }); }); + it('should return error', function (done) { + table.runScan.yields(new Error('Fail')); + + new Scan(table, serializer).exec(function (err, results) { + expect(err).to.exist; + expect(results).to.not.exist; + done(); + }); + }); + + it('should run scan after encountering a retryable exception', function (done) { + var err = new Error('RetryableException'); + err.retryable = true; + + table.runScan + .onCall(0).yields(err) + .onCall(1).yields(err) + .onCall(2).yields(null, {Items : [{name : 'foo'}]}); + + new Scan(table, serializer).exec(function (err, data) { + expect(err).to.not.exist; + expect(data).to.exist; + expect(data.Items).to.have.length(1); + + expect(table.runScan.calledThrice).to.be.true; + done(); + }); + }); + + }); describe('#limit', function () { it('should set the limit', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - var scan = new Scan(table, serializer).limit(10); scan.request.Limit.should.equal(10); }); + + it('should throw when limit is zero', function () { + var scan = new Scan(table, serializer); + expect(function () { + scan.limit(0); + }).to.throw('Limit must be greater than 0'); + }); + }); - describe('#scan', function () { + describe('#attributes', function () { it('should set array attributes to get', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var scan = new Scan(table, serializer).attributes(['created']); scan.request.AttributesToGet.should.eql(['created']); }); it('should set single attribute to get', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var scan = new Scan(table, serializer).attributes('email'); scan.request.AttributesToGet.should.eql(['email']); }); @@ -95,9 +149,6 @@ describe('Scan', function () { describe('#startKey', function () { it('should set start Key to hash', function () { - schema.String('name', {hashKey: true}); - schema.String('email'); - var key = {name: {S: 'tim'}}; serializer.buildKey.returns(key); @@ -107,10 +158,6 @@ describe('Scan', function () { }); it('should set start Key to hash + range', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var key = {name: {S: 'tim'}, email : {S: 'foo@example.com'}}; serializer.buildKey.returns(key); @@ -123,10 +170,6 @@ describe('Scan', function () { describe('#select', function () { it('should set select Key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var scan = new Scan(table, serializer).select('COUNT'); scan.request.Select.should.eql('COUNT'); @@ -136,19 +179,11 @@ describe('Scan', function () { describe('#ReturnConsumedCapacity', function () { it('should set return consumed capacity Key to passed in value', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var scan = new Scan(table, serializer).returnConsumedCapacity('TOTAL'); scan.request.ReturnConsumedCapacity.should.eql('TOTAL'); }); it('should set return consumed capacity Key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var scan = new Scan(table, serializer).returnConsumedCapacity(); scan.request.ReturnConsumedCapacity.should.eql('TOTAL'); @@ -158,10 +193,6 @@ describe('Scan', function () { describe('#segment', function () { it('should set both segment and total segments keys', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - var scan = new Scan(table, serializer).segments(0, 4); scan.request.Segment.should.eql(0); @@ -174,122 +205,114 @@ describe('Scan', function () { var scan; beforeEach(function () { - scan = new Scan(table, serializer); - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Date('created', {secondaryIndex: true}); - schema.NumberSet('scores'); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + created : Joi.date(), + scores : Schema.types.numberSet() + }, + indexes : [{ hashKey : 'name', rangeKey : 'created', type : 'local', name : 'CreatedIndex'}] + }; + + schema = new Schema(config); + table.schema = schema; + + scan = new Scan(table, serializer); }); it('should have equals clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - scan = scan.where('email').equals('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'EQ'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'EQ'}); }); it('should have not equals clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - scan = scan.where('email').ne('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'NE'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'NE'}); }); it('should have less than or equal clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - scan = scan.where('email').lte('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LE'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LE'}); }); it('should have less than clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - scan = scan.where('email').lt('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LT'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'LT'}); }); it('should have greater than or equal clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - scan = scan.where('email').gte('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GE'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GE'}); }); it('should have greater than clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); - scan = scan.where('email').gt('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GT'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'GT'}); }); it('should have not null clause', function() { scan = scan.where('email').notNull(); - scan.request.ScanFilter.email.should.eql({ComparisonOperator: 'NOT_NULL'}); + internals.assertScanFilter(scan, {ComparisonOperator: 'NOT_NULL'}); }); it('should have null clause', function() { scan = scan.where('email').null(); - scan.request.ScanFilter.email.should.eql({ComparisonOperator: 'NULL'}); + internals.assertScanFilter(scan, {ComparisonOperator: 'NULL'}); }); it('should have contains clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); scan = scan.where('email').contains('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'CONTAINS'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'CONTAINS'}); }); it('should not pass a number set when making contains call', function() { - serializer.serializeItem.withArgs(schema, {scores: 2}, {convertSets: true}).returns({scores: {N: '2'}}); scan = scan.where('scores').contains(2); - scan.request.ScanFilter.scores.should.eql({AttributeValueList: [{N: '2'}], ComparisonOperator: 'CONTAINS'}); + internals.assertScanFilter(scan, {AttributeValueList: [{N: '2'}], ComparisonOperator: 'CONTAINS'}); }); it('should have not contains clause', function() { - serializer.serializeItem.returns({email: {S: 'foo@example.com'}}); scan = scan.where('email').notContains('foo@example.com'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'NOT_CONTAINS'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo@example.com'}], ComparisonOperator: 'NOT_CONTAINS'}); }); - it('should have in clause', function() { - serializer.serializeItem.withArgs(schema, {email: 'foo@example.com'}).returns({email: {S: 'foo@example.com'}}); - serializer.serializeItem.withArgs(schema, {email: 'test@example.com'}).returns({email: {S: 'test@example.com'}}); - + it.skip('should have in clause', function() { + // TODO there is a bug in dynamodb-doc lib + // that needs to get fixed til this test can pass scan = scan.where('email').in(['foo@example.com', 'test@example.com']); - scan.request.ScanFilter.email.should.eql({ + var expected ={ AttributeValueList: [{S: 'foo@example.com'}, {S: 'test@example.com'}], ComparisonOperator: 'IN' - }); + }; + + internals.assertScanFilter(scan, expected); }); it('should have begins with clause', function() { - serializer.serializeItem.returns({email: {S: 'foo'}}); - scan = scan.where('email').beginsWith('foo'); - scan.request.ScanFilter.email.should.eql({AttributeValueList: [{S: 'foo'}], ComparisonOperator: 'BEGINS_WITH'}); + internals.assertScanFilter(scan, {AttributeValueList: [{S: 'foo'}], ComparisonOperator: 'BEGINS_WITH'}); }); it('should have between clause', function() { - serializer.serializeItem.withArgs(schema, {email: 'bob@bob.com'}).returns({email: {S: 'bob@bob.com'}}); - serializer.serializeItem.withArgs(schema, {email: 'foo@foo.com'}).returns({email: {S: 'foo@foo.com'}}); - - scan = scan.where('email').between(['bob@bob.com', 'foo@foo.com']); + scan = scan.where('email').between('bob@bob.com', 'foo@foo.com'); - var expect = { + var expected = { AttributeValueList: [ {S: 'bob@bob.com'}, {S: 'foo@foo.com'} @@ -297,23 +320,27 @@ describe('Scan', function () { ComparisonOperator: 'BETWEEN' }; - scan.request.ScanFilter.email.should.eql(expect); + internals.assertScanFilter(scan, expected); }); it('should have multiple filters', function() { - serializer.serializeItem.withArgs(schema, {email: 'foo'}).returns({email: {S: 'foo'}}); - serializer.serializeItem.withArgs(schema, {name: 'Tim'}).returns({name: {S: 'Tim'}}); - scan = scan .where('name').equals('Tim') .where('email').beginsWith('foo'); - var expect = { - name : {AttributeValueList: [{S: 'Tim'}], ComparisonOperator: 'EQ'}, - email : {AttributeValueList: [{S: 'foo'}], ComparisonOperator: 'BEGINS_WITH'} - }; + var expected = [ + {AttributeValueList: [{S: 'Tim'}], ComparisonOperator: 'EQ'}, + {AttributeValueList: [{S: 'foo'}], ComparisonOperator: 'BEGINS_WITH'} + ]; - scan.request.ScanFilter.should.eql(expect); + internals.assertScanFilter(scan, expected); + }); + + it('should convert date to iso string', function() { + var d = new Date(); + scan = scan.where('created').equals(d); + + internals.assertScanFilter(scan, {AttributeValueList: [{S: d.toISOString()}], ComparisonOperator: 'EQ'}); }); }); @@ -321,14 +348,44 @@ describe('Scan', function () { describe('#loadAll', function () { it('should set load all option to true', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + var scan = new Scan(table, serializer).loadAll(); - var scan = new Scan(table, serializer).limit(10); + scan.options.loadAll.should.be.true; + }); + }); + + + describe('#filterExpression', function () { + + it('should set filter expression', function () { + var scan = new Scan(table, serializer).filterExpression('Postedby = :val'); + scan.request.FilterExpression.should.equal('Postedby = :val'); + }); + }); - scan.options.loadAll = true; + describe('#expressionAttributeValues', function () { + + it('should set expression attribute values', function () { + var scan = new Scan(table, serializer).expressionAttributeValues({ ':val' : 'test'}); + scan.request.ExpressionAttributeValues.should.eql({ ':val' : 'test'}); }); + }); + describe('#expressionAttributeNames', function () { + + it('should set expression attribute names', function () { + var scan = new Scan(table, serializer).expressionAttributeNames({ '#name' : 'name'}); + scan.request.ExpressionAttributeNames.should.eql({ '#name' : 'name'}); + }); + }); + + describe('#projectionExpression', function () { + + it('should set projection expression', function () { + var scan = new Scan(table, serializer).projectionExpression( '#name, #email'); + scan.request.ProjectionExpression.should.eql('#name, #email'); + }); + }); }); diff --git a/test/schema-test.js b/test/schema-test.js index ec6aa9a..c1e430f 100644 --- a/test/schema-test.js +++ b/test/schema-test.js @@ -3,191 +3,350 @@ var Schema = require('../lib/schema'), chai = require('chai'), expect = chai.expect, + Joi = require('joi'), + _ = require('lodash'), sinon = require('sinon'); chai.should(); describe('schema', function () { - var schema; - beforeEach(function () { - schema = new Schema(); - }); + describe('setup', function () { + + it('should set hash key', function () { + var config = { + hashKey: 'id' + }; - describe('#String', function () { + var s = new Schema(config); + s.hashKey.should.equal('id'); + }); - it('should do something', function () { - schema.String('name'); + it('should set hash and range key', function () { + var config = { + hashKey : 'id', + rangeKey : 'date' + }; - schema.attrs.should.have.keys(['name']); - schema.attrs.name.type._type.should.equal('string'); + var s = new Schema(config); + s.hashKey.should.equal('id'); + s.rangeKey.should.equal('date'); }); - it('should set hashkey', function () { - schema.String('name', {hashKey: true}); + it('should set table name to string', function () { + var config = { + hashKey : 'id', + tableName : 'test-table' + }; - schema.hashKey.should.equal('name'); + var s = new Schema(config); + s.tableName.should.equal('test-table'); }); - it('should set rangeKey', function () { - schema.String('name', {rangeKey: true}); + it('should set table name to function', function () { + var func = function () { return 'test-table'; }; + + var config = { + hashKey : 'id', + tableName : func + }; - schema.rangeKey.should.equal('name'); + var s = new Schema(config); + s.tableName.should.equal(func); }); - it('should set secondaryIndexes', function () { - schema.String('name', {secondaryIndex: true}); + it('should add timestamps to schema', function () { + var config = { + hashKey : 'id', + timestamps : true, + schema : { + id : Joi.string() + } + }; + + var s = new Schema(config); + s.timestamps.should.be.true; + + expect(s._modelSchema.describe().children).to.have.keys(['id', 'createdAt', 'updatedAt']); + + s._modelDatatypes.should.eql({ + id : 'S', + createdAt : 'DATE', + updatedAt : 'DATE', + }); + }); + + + it('should throw error when hash key is not present', function () { + var config = {rangeKey : 'foo'}; + + expect(function () { + new Schema(config); + }).to.throw(/hashKey is required/); - schema.secondaryIndexes.should.eql(['name']); }); - }); + it('should setup local secondary index when both hash and range keys are given', function () { + var config = { + hashKey : 'foo', + indexes : [ + {hashKey : 'foo', rangeKey : 'bar', type:'local', name : 'LocalBarIndex'} + ] + }; + + var s = new Schema(config); + s.secondaryIndexes.should.include.keys('LocalBarIndex'); + s.globalIndexes.should.be.empty; + }); - describe('#Number', function () { - it('should set as number', function () { - schema.Number('age'); + it('should setup local secondary index when only range key is given', function () { + var config = { + hashKey : 'foo', + indexes : [ + {rangeKey : 'bar', type:'local', name : 'LocalBarIndex'} + ] + }; + + var s = new Schema(config); + s.secondaryIndexes.should.include.keys('LocalBarIndex'); + s.globalIndexes.should.be.empty; + }); - schema.attrs.should.have.keys(['age']); - schema.attrs.age.type._type.should.equal('number'); + it('should throw when local index rangeKey isnt present', function () { + var config = { + hashKey : 'foo', + indexes : [ + {hashKey : 'foo', type:'local', name : 'LocalBarIndex'} + ] + }; + + expect(function () { + new Schema(config); + }).to.throw(/rangeKey.*missing/); }); - }); - describe('#Boolean', function () { - it('should set as boolean', function () { - schema.Boolean('agree'); + it('should throw when local index hashKey does not match the tables hashKey', function () { + var config = { + hashKey : 'foo', + indexes : [ + {hashKey : 'bar', rangeKey: 'date', type:'local', name : 'LocalDateIndex'} + ] + }; + + expect(function () { + new Schema(config); + }).to.throw(/hashKey must be one of context:hashKey/); + }); - schema.attrs.should.have.keys(['agree']); - schema.attrs.agree.type._type.should.equal('boolean'); + it('should setup global index', function () { + var config = { + hashKey : 'foo', + indexes : [ + {hashKey : 'bar', type:'global', name : 'GlobalBarIndex'} + ] + }; + + var s = new Schema(config); + s.globalIndexes.should.include.keys('GlobalBarIndex'); + s.secondaryIndexes.should.be.empty; }); - }); - describe('#Date', function () { - it('should set as date', function () { - schema.Date('created'); + it('should throw when global index hashKey is not present', function () { + var config = { + hashKey : 'foo', + indexes : [ + {rangeKey: 'date', type:'global', name : 'GlobalDateIndex'} + ] + }; + + expect(function () { + new Schema(config); + }).to.throw(/hashKey is required/); + }); - schema.attrs.should.have.keys(['created']); - schema.attrs.created.type._type.should.equal('date'); + it('should parse schema data types', function () { + var config = { + hashKey : 'foo', + schema : Joi.object().keys({ + foo : Joi.string().default('foobar'), + date : Joi.date().default(Date.now), + count: Joi.number(), + flag: Joi.boolean(), + nums : Joi.array().includes(Joi.number()).meta({dynamoType : 'NS'}), + items : Joi.array(), + data : Joi.object().keys({ + stuff : Joi.array().meta({dynamoType : 'SS'}), + nested : { + first : Joi.string(), + last : Joi.string(), + nicks : Joi.array().meta({dynamoType : 'SS', foo : 'bar'}), + ages : Joi.array().meta({foo : 'bar'}).meta({dynamoType : 'NS'}), + pics : Joi.array().meta({dynamoType : 'BS'}), + bin : Joi.binary() + } + }) + }) + }; + + var s = new Schema(config); + + s._modelSchema.should.eql(config.schema); + s._modelDatatypes.should.eql({ + foo : 'S', + date : 'DATE', + count : 'N', + flag : 'BOOL', + nums : 'NS', + items : 'L', + data : { + nested : { + ages : 'NS', + first : 'S', + last : 'S', + nicks : 'SS', + pics : 'BS', + bin : 'B' + }, + stuff : 'SS' + } + }); }); + }); - describe('#StringSet', function () { + describe('#stringSet', function () { it('should set as string set', function () { - schema.StringSet('names'); - - schema.attrs.should.have.keys(['names']); - schema.attrs.names.type._type.should.equal('stringSet'); + var config = { + hashKey : 'email', + schema : { + email : Joi.string().email(), + names : Schema.types.stringSet() + } + }; + + var s = new Schema(config); + + s._modelDatatypes.should.eql({ + email : 'S', + names : 'SS', + }); }); }); - describe('#NumberSet', function () { + describe('#numberSet', function () { it('should set as number set', function () { - schema.NumberSet('scores'); - - schema.attrs.should.have.keys(['scores']); - schema.attrs.scores.type._type.should.equal('numberSet'); + var config = { + hashKey : 'email', + schema : { + email : Joi.string().email(), + nums : Schema.types.numberSet() + } + }; + + var s = new Schema(config); + + s._modelDatatypes.should.eql({ + email : 'S', + nums : 'NS', + }); }); }); - describe('#UUID', function () { - it('should set as uuid with default uuid function', function () { - schema.UUID('id'); - - schema.attrs.should.have.keys(['id']); - schema.attrs.id.options.default.should.exist; - schema.attrs.id.type._type.should.equal('uuid'); + describe('#binarySet', function () { + it('should set as binary set', function () { + var config = { + hashKey : 'email', + schema : { + email : Joi.string().email(), + pics : Schema.types.binarySet() + } + }; + + var s = new Schema(config); + + s._modelDatatypes.should.eql({ + email : 'S', + pics : 'BS', + }); }); + }); - it('should set as uuid with default as given value', function () { - schema.UUID('id', {default : '123'}); - schema.attrs.should.have.keys(['id']); - schema.attrs.id.options.default.should.equal('123'); - schema.attrs.id.type._type.should.equal('uuid'); + describe('#uuid', function () { + it('should set as uuid with default uuid function', function () { + var config = { + hashKey : 'id', + schema : { + id : Schema.types.uuid(), + } + }; + + var s = new Schema(config); + expect(s.applyDefaults({}).id).should.not.be.empty; }); }); - describe('#TimeUUID', function () { + describe('#timeUUID', function () { it('should set as TimeUUID with default v1 uuid function', function () { - schema.TimeUUID('timeid'); + var config = { + hashKey : 'id', + schema : { + id : Schema.types.timeUUID(), + } + }; - schema.attrs.should.have.keys(['timeid']); - schema.attrs.timeid.options.default.should.exist; - schema.attrs.timeid.type._type.should.equal('timeuuid'); - }); - - it('should set as uuid with default as given value', function () { - schema.TimeUUID('stamp', {default : '123'}); + var s = new Schema(config); + expect(s.applyDefaults({}).id).should.not.be.empty; - schema.attrs.should.have.keys(['stamp']); - schema.attrs.stamp.options.default.should.equal('123'); - schema.attrs.stamp.type._type.should.equal('timeuuid'); }); - }); + }); describe('#validate', function () { it('should return no err for string', function() { - schema.String('email', {hashKey: true}); + var config = { + hashKey : 'email', + schema : { + email : Joi.string().email().required() + } + }; - expect(schema.validate({email: 'foo@bar.com'})).to.be.null; - }); - - it('should return err when hashkey isnt set', function() { - schema.String('email', {hashKey: true}); - schema.String('name'); + var s = new Schema(config); - var err = schema.validate({name : 'foo bar'}); - expect(err).to.exist; + expect(s.validate({email: 'foo@bar.com'}).error).to.be.null; }); it('should return no error for valid date object', function() { - schema.Date('created', {hashKey: true}); + var config = { + hashKey : 'created', + schema : { + created : Joi.date() + } + }; - expect(schema.validate({created: new Date()})).to.be.null; - }); + var s = new Schema(config); - it('should return no error when using Date.now', function() { - schema.Date('created', {hashKey: true}); - - expect(schema.validate({created: Date.now()})).to.be.null; - }); - - }); - - describe('#defaults', function () { - it('should return default option set on hashkey', function () { - schema.String('email', {hashKey: true, default: 'foo@bar.com'}); - - schema.defaults().should.have.keys(['email']); - }); - - it('should return attributes that have defautls', function () { - schema.String('email', {hashKey: true}); - schema.String('name', {default: 'Foo Bar'}); - schema.Number('age', {default: 3}); - schema.Number('posts', {default: 0}); - schema.Boolean('terms', {default: false}); - - schema.defaults().should.have.keys(['name', 'age', 'posts', 'terms']); + expect(s.validate({created: new Date()}).error).to.be.null; + expect(s.validate({created: Date.now()}).error).to.be.null; }); - it('should return empty object when no defaults exist', function () { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); - - schema.defaults().should.be.empty; - }); }); describe('#applyDefaults', function () { it('should apply default values', function () { - schema.String('email', {hashKey: true}); - schema.String('name', {default: 'Foo Bar'}); - schema.Number('age', {default: 3}); + var config = { + hashKey : 'email', + schema : { + email : Joi.string(), + name : Joi.string().default('Foo Bar').required(), + age : Joi.number().default(3) + } + }; - var d = schema.applyDefaults({email: 'foo@bar.com'}); + var s = new Schema(config); + + var d = s.applyDefaults({email: 'foo@bar.com'}); d.email.should.equal('foo@bar.com'); d.name.should.equal('Foo Bar'); @@ -197,53 +356,34 @@ describe('schema', function () { it('should return result of default functions', function () { var clock = sinon.useFakeTimers(Date.now()); - schema.String('email', {hashKey: true}); - schema.Date('created', {default: Date.now}); - - var d = schema.applyDefaults({email: 'foo@bar.com'}); - - d.created.should.equal(Date.now()); + var config = { + hashKey : 'email', + schema : { + email : Joi.string(), + created : Joi.date().default(Date.now), + data : { + name : Joi.string().default('Tim Tester'), + nick : Joi.string().default(_.constant('foo bar')) + } + } + }; + + var s = new Schema(config); + + var d = s.applyDefaults({email: 'foo@bar.com', data : {} }); + + d.should.eql({ + email : 'foo@bar.com', + created : Date.now(), + data : { + name : 'Tim Tester', + nick : 'foo bar' + } + }); clock.restore(); }); - it('should modify passed in data', function () { - schema.String('email', {hashKey: true}); - schema.String('name', {default: 'Foo Bar'}); - schema.Number('age', {default: 3}); - - var data = {email : 'test@example.com'}; - schema.applyDefaults(data); - - data.email.should.equal('test@example.com'); - data.name.should.equal('Foo Bar'); - data.age.should.equal(3); - }); - - it('should modify anything when no defaults are set', function () { - schema.String('email'); - schema.String('name'); - schema.Number('age'); - - var d = schema.applyDefaults({email: 'foo@bar.com'}); - - d.email.should.equal('foo@bar.com'); - expect(d.name).to.not.exist; - expect(d.age).to.not.exist; - }); }); - describe('#globalIndex', function () { - - it('should set globalIndexes', function () { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); - - schema.globalIndex('GameTitleIndex', {hashKey: 'gameTitle', rangeKey : 'topScore'}); - - schema.globalIndexes.should.include.keys('GameTitleIndex'); - }); - - }); }); diff --git a/test/serializer-test.js b/test/serializer-test.js index 3a619e0..4d34cd7 100644 --- a/test/serializer-test.js +++ b/test/serializer-test.js @@ -1,574 +1,755 @@ 'use strict'; var serializer = require('../lib/serializer'), - chai = require('chai'), - expect = chai.expect, - Schema = require('../lib/schema'), - zlib = require('zlib'), - async = require('async'); + chai = require('chai'), + expect = chai.expect, + Schema = require('../lib/schema'), + helper = require('./test-helper'), + Joi = require('joi'); chai.should(); describe('Serializer', function () { - var schema; - - beforeEach(function () { - schema = new Schema(); - }); + var docClient = helper.mockDocClient(); describe('#buildKeys', function () { it('should handle string hash key', function () { - schema.String('email', {hashKey: true}); + var config = { + hashKey: 'email', + schema : { + email : Joi.string() + } + }; - var keys = serializer.buildKey('test@test.com', null, schema); + var s = new Schema(config); - keys.should.eql({email: {S: 'test@test.com'}}); + var keys = serializer.buildKey('test@test.com', null, s); + + keys.should.eql({email: 'test@test.com'}); }); it('should handle number hash key', function () { - schema.Number('year', {hashKey: true}); + var config = { + hashKey: 'year', + schema : { + year : Joi.number() + } + }; - var keys = serializer.buildKey(1999, null, schema); + var s = new Schema(config); - keys.should.eql({year: {N: '1999'}}); + var keys = serializer.buildKey(1999, null, s); + + keys.should.eql({year: 1999}); }); it('should handle date hash key', function () { - schema.Date('timestamp', {hashKey: true}); + var config = { + hashKey: 'timestamp', + schema : { + timestamp : Joi.date() + } + }; + + var s = new Schema(config); var d = new Date(); - var keys = serializer.buildKey(d, null, schema); + var keys = serializer.buildKey(d, null, s); - keys.should.eql({timestamp: {S: d.toISOString()}}); + keys.should.eql({timestamp: d.toISOString()}); }); it('should handle string hash and range key', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.String('slug'); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + slug : Joi.string(), + } + }; + + var s = new Schema(config); - var keys = serializer.buildKey('Tim Tester', 'test@test.com', schema); + var keys = serializer.buildKey('Tim Tester', 'test@test.com', s); - keys.should.eql({name: {S: 'Tim Tester'}, email: {S : 'test@test.com'}}); + keys.should.eql({name: 'Tim Tester', email: 'test@test.com'}); }); it('should handle number hash and range key', function () { - schema.Number('year', {hashKey: true}); - schema.Number('num', {rangeKey: true}); + var config = { + hashKey: 'year', + rangeKey: 'num', + schema : { + year : Joi.number(), + num : Joi.number(), + } + }; + + var s = new Schema(config); - var keys = serializer.buildKey(1988, 1.4, schema); + var keys = serializer.buildKey(1988, 1.4, s); - keys.should.eql({year: {N: '1988'}, num: {N : '1.4'}}); + keys.should.eql({year: 1988, num: 1.4}); }); it('should handle object containing the hash key', function () { - schema.Number('year', {hashKey: true}); - schema.String('name', {rangeKey: true}); - schema.String('slug'); + var config = { + hashKey: 'year', + rangeKey: 'name', + schema : { + year : Joi.number(), + name : Joi.string(), + slug : Joi.string(), + } + }; - var keys = serializer.buildKey({year: 1988, name : 'Joe'}, null, schema); + var s = new Schema(config); - keys.should.eql({year: {N: '1988'}, name: {S: 'Joe'}}); + var keys = serializer.buildKey({year: 1988, name : 'Joe'}, null, s); + + keys.should.eql({year: 1988, name: 'Joe'}); }); it('should handle local secondary index keys', function () { - schema.String('email', {hashKey: true}); - schema.Number('age', {rangeKey: true}); - schema.String('name', { secondaryIndex: true }); + var config = { + hashKey: 'email', + rangeKey: 'age', + schema : { + email : Joi.string(), + age : Joi.number(), + name : Joi.string(), + }, + indexes : [{ + hashKey : 'email', rangeKey : 'name', type : 'local', name : 'NameIndex' + }] + }; + + var s = new Schema(config); var data = { email : 'test@example.com', age: 22, name: 'Foo Bar' }; - var keys = serializer.buildKey(data, null, schema); + var keys = serializer.buildKey(data, null, s); - keys.should.eql({email: {S: 'test@example.com'}, age: {N : '22'}, name: {S: 'Foo Bar'}}); + keys.should.eql({email: 'test@example.com', age: 22, name: 'Foo Bar'}); }); it('should handle global secondary index keys', function () { - schema.String('email', {hashKey: true}); - schema.Number('age'); - schema.String('name'); - - schema.globalIndex('GameTitleIndex', { - hashKey: 'age', - rangeKey: 'name' - }); + var config = { + hashKey: 'email', + rangeKey: 'age', + schema : { + email : Joi.string(), + age : Joi.number(), + name : Joi.string(), + }, + indexes : [{ + hashKey : 'age', rangeKey : 'name', type : 'global', name : 'AgeNameIndex' + }] + }; + + var s = new Schema(config); var data = { email : 'test@example.com', age: 22, name: 'Foo Bar' }; - var keys = serializer.buildKey(data, null, schema); + var keys = serializer.buildKey(data, null, s); - keys.should.eql({email: {S: 'test@example.com'}, age: {N : '22'}, name: {S: 'Foo Bar'}}); + keys.should.eql({email: 'test@example.com', age: 22, name: 'Foo Bar'}); }); it('should handle boolean global secondary index key', function () { - schema.String('email', {hashKey: true}); - schema.Number('age'); - schema.String('name'); - schema.Boolean('adult'); - - schema.globalIndex('GameTitleIndex', { - hashKey: 'adult', - rangeKey: 'email' - }); + var config = { + hashKey: 'email', + rangeKey: 'age', + schema : { + email : Joi.string(), + age : Joi.number(), + name : Joi.string(), + adult : Joi.boolean(), + }, + indexes : [{ + hashKey : 'adult', rangeKey : 'email', type : 'global', name : 'AdultEmailIndex' + }] + }; + + var s = new Schema(config); var data = { email : 'test@example.com', adult: false }; - var keys = serializer.buildKey(data, null, schema); + var keys = serializer.buildKey(data, null, s); - keys.should.eql({email: {S: 'test@example.com'}, adult: {N : '0'}}); + keys.should.eql({email: 'test@example.com', adult: false}); }); }); - describe('#deserializeKeys', function () { - - it('should handle string hash key', function () { - schema.String('email', {hashKey: true}); - schema.String('name'); - - var keys = serializer.deserializeKeys(schema, {email : {S : 'test@example.com'}, name : {S: 'Foo Bar'}}); - - keys.should.eql({email: 'test@example.com'}); - }); - - it('should handle range key', function () { - schema.String('email', {hashKey: true}); - schema.Number('age', {rangeKey: true}); - schema.String('name'); - - var serializedItem = {email : {S : 'test@example.com'}, age : {N : '22'}, name : {S: 'Foo Bar'}}; - var keys = serializer.deserializeKeys(schema, serializedItem); - - keys.should.eql({email: 'test@example.com', age: 22}); - }); - - it('should deserialize local secondary index keys', function () { - schema.String('email', {hashKey: true}); - schema.Number('age', {rangeKey: true}); - schema.String('name', { secondaryIndex: true }); - - var serializedItem = {email : {S : 'test@example.com'}, age : {N : '22'}, name : {S: 'Foo Bar'}}; - var keys = serializer.deserializeKeys(schema, serializedItem); - - keys.should.eql({email: 'test@example.com', age: 22, name: 'Foo Bar'}); - }); - - it('should deserialize global secondary index keys', function () { - schema.String('email', {hashKey: true}); - schema.Number('age'); - schema.String('name'); - - schema.globalIndex('GameTitleIndex', { - hashKey: 'age', - rangeKey: 'name' - }); - - var serializedItem = {email : {S : 'test@example.com'}, age : {N : '22'}, name : {S: 'Foo Bar'}}; - var keys = serializer.deserializeKeys(schema, serializedItem); - - keys.should.eql({email: 'test@example.com', age: 22, name: 'Foo Bar'}); - }); - }); - describe('#serializeItem', function () { it('should serialize string attribute', function () { - schema.String('name'); + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; - var item = serializer.serializeItem(schema, {name: 'Tim Tester'}); + var s = new Schema(config); - item.should.eql({name: {S: 'Tim Tester'}}); + var item = serializer.serializeItem(s, {name: 'Tim Tester'}); + + item.should.eql({name: 'Tim Tester'}); }); it('should serialize number attribute', function () { - schema.Number('age'); + var config = { + hashKey: 'age', + schema : { + age : Joi.number(), + } + }; - var item = serializer.serializeItem(schema, {age: 21}); + var s = new Schema(config); - item.should.eql({age: {N: '21'}}); + var item = serializer.serializeItem(s, {age: 21}); + + item.should.eql({age: 21}); }); it('should serialize binary attribute', function () { - schema.Binary('data'); + var config = { + hashKey: 'data', + schema : { + data : Joi.binary(), + bin : Joi.binary() + } + }; - var item = serializer.serializeItem(schema, {data: 'hello'}); + var s = new Schema(config); - item.should.eql({data: {B: 'aGVsbG8='}}); + var item = serializer.serializeItem(s, {data: 'hello', bin : new Buffer('binary')}); + + item.should.eql({data: new Buffer('hello'), bin : new Buffer('binary')}); }); it('should serialize number attribute with value zero', function () { - schema.Number('age'); + var config = { + hashKey: 'age', + schema : { + age : Joi.number(), + } + }; - var item = serializer.serializeItem(schema, {age: 0}); + var s = new Schema(config); - item.should.eql({age: {N: '0'}}); + var item = serializer.serializeItem(s, {age: 0}); + + item.should.eql({age: 0}); }); it('should serialize boolean attribute', function () { - schema.Boolean('agree'); + var config = { + hashKey: 'agree', + schema : { + agree : Joi.boolean(), + } + }; - serializer.serializeItem(schema, {agree: true}).should.eql({agree: {N: '1'}}); - serializer.serializeItem(schema, {agree: 'true'}).should.eql({agree: {N: '1'}}); + var s = new Schema(config); - serializer.serializeItem(schema, {agree: false}).should.eql({agree: {N: '0'}}); - serializer.serializeItem(schema, {agree: 'false'}).should.eql({agree: {N: '0'}}); + serializer.serializeItem(s, {agree: true}).should.eql({agree: true}); + serializer.serializeItem(s, {agree: 'true'}).should.eql({agree: true}); + + serializer.serializeItem(s, {agree: false}).should.eql({agree: false}); + serializer.serializeItem(s, {agree: 'false'}).should.eql({agree: false}); //serializer.serializeItem(schema, {agree: null}).should.eql({agree: {N: '0'}}); - serializer.serializeItem(schema, {agree: 0}).should.eql({agree: {N: '0'}}); + serializer.serializeItem(s, {agree: 0}).should.eql({agree: false}); }); it('should serialize date attribute', function () { - schema.Date('time'); + var config = { + hashKey: 'time', + schema : { + time : Joi.date(), + } + }; + + var s = new Schema(config); var d = new Date(); - var item = serializer.serializeItem(schema, {time: d}); + var item = serializer.serializeItem(s, {time: d}); + item.should.eql({time: d.toISOString()}); - item.should.eql({time: {S: d.toISOString()}}); + var now = Date.now(); + var item2 = serializer.serializeItem(s, {time: now}); + item2.should.eql({time: new Date(now).toISOString()}); }); it('should serialize string set attribute', function () { - schema.StringSet('names'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + names : Schema.types.stringSet(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItem(schema, {names: ['Tim', 'Steve', 'Bob']}); + var item = serializer.serializeItem(s, {names: ['Tim', 'Steve', 'Bob']}); - item.should.eql({names: {SS: ['Tim', 'Steve', 'Bob']}}); + var stringSet = docClient.Set(['Tim', 'Steve', 'Bob'], 'S'); + + item.names.datatype.should.eql('SS'); + item.names.contents.should.eql(stringSet.contents); }); it('should serialize single string set attribute', function () { - schema.StringSet('names'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + names : Schema.types.stringSet(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItem(schema, {names: 'Tim'}); + var item = serializer.serializeItem(s, {names: 'Tim'}); - item.should.eql({names: {SS: ['Tim']}}); + var stringSet = docClient.Set(['Tim'], 'S'); + item.names.datatype.should.eql('SS'); + item.names.contents.should.eql(stringSet.contents); }); it('should number set attribute', function () { - schema.NumberSet('scores'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + scores : Schema.types.numberSet(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItem(schema, {scores: [2, 4, 6, 8]}); + var item = serializer.serializeItem(s, {scores: [2, 4, 6, 8]}); - item.should.eql({scores: {NS: ['2', '4', '6', '8']}}); + var numberSet = docClient.Set([2, 4, 6, 8], 'N'); + item.scores.datatype.should.eql('NS'); + item.scores.contents.should.eql(numberSet.contents); }); it('should single number set attribute', function () { - schema.NumberSet('scores'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + scores : Schema.types.numberSet(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItem(schema, {scores: 2}); + var item = serializer.serializeItem(s, {scores: 2}); - item.should.eql({scores: {NS: ['2']}}); + var numberSet = docClient.Set([2], 'N'); + item.scores.datatype.should.eql('NS'); + item.scores.contents.should.eql(numberSet.contents); }); it('should serialize binary set attribute', function () { - schema.BinarySet('data'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + data : Schema.types.binarySet(), + } + }; - var item = serializer.serializeItem(schema, {data: ['hello', 'world']}); + var s = new Schema(config); - item.should.eql({data: {BS: ['aGVsbG8=', 'd29ybGQ=']}}); + var item = serializer.serializeItem(s, {data: ['hello', 'world']}); + + var binarySet = docClient.Set([new Buffer('hello'), new Buffer('world')], 'B'); + item.data.datatype.should.eql('BS'); + item.data.contents.should.eql(binarySet.contents); }); it('should serialize single binary set attribute', function () { - schema.BinarySet('data'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + data : Schema.types.binarySet(), + } + }; - var item = serializer.serializeItem(schema, {data: 'hello'}); + var s = new Schema(config); - item.should.eql({data: {BS: ['aGVsbG8=']}}); + var item = serializer.serializeItem(s, {data: 'hello'}); + + var binarySet = docClient.Set([new Buffer('hello')], 'B'); + item.data.datatype.should.eql('BS'); + item.data.contents.should.eql(binarySet.contents); }); it('should serialize uuid attribute', function () { - schema.UUID('id'); + var config = { + hashKey: 'id', + schema : { + id : Schema.types.uuid(), + } + }; + + var s = new Schema(config); var id = '1234-5123-2342-1234'; - var item = serializer.serializeItem(schema, {id: id}); + var item = serializer.serializeItem(s, {id: id}); - item.should.eql({id: {S: id}}); + item.should.eql({id: id}); }); it('should serialize TimeUUId attribute', function () { - schema.TimeUUID('timeid'); + var config = { + hashKey: 'timeid', + schema : { + timeid : Schema.types.timeUUID(), + } + }; + + var s = new Schema(config); var timeid = '1234-5123-2342-1234'; - var item = serializer.serializeItem(schema, {timeid: timeid}); + var item = serializer.serializeItem(s, {timeid: timeid}); - item.should.eql({timeid: {S: timeid}}); + item.should.eql({timeid: timeid}); }); it('should return null', function () { - schema.String('email'); - schema.NumberSet('scores'); - - var item = serializer.serializeItem(schema, null); - - expect(item).to.be.null; - }); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + scores : Schema.types.numberSet(), + } + }; - it('should convert string set to a string', function () { - schema.StringSet('names'); + var s = new Schema(config); - var item = serializer.serializeItem(schema, {names: 'Bob'}, {convertSets: true}); + var item = serializer.serializeItem(s, null); - item.should.eql({names: {S: 'Bob'}}); + expect(item).to.be.null; }); it('should serialize string attribute for expected', function () { - schema.String('name'); - - var item = serializer.serializeItem(schema, {name: 'Tim Tester'}, {expected : true}); - - item.should.eql({name: { 'Value' : {S: 'Tim Tester'}}}); - }); + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; - it('should serialize string attribute for expected exists false', function () { - schema.String('name'); + var s = new Schema(config); - var item = serializer.serializeItem(schema, {name: {Exists: false}}, {expected : true}); + var item = serializer.serializeItem(s, {name: 'Tim Tester'}, {expected : true}); - item.should.eql({name: { 'Exists' : false}}); + item.should.eql({name: { 'Value' : 'Tim Tester'}}); }); - }); - - describe('#deserializeItem', function () { - it('should parse string attribute', function () { - schema.String('name'); + it('should serialize string attribute for expected exists false', function () { + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + } + }; - var itemResp = {name : {S: 'Tim Tester'} }; + var s = new Schema(config); - var item = serializer.deserializeItem(schema, itemResp); + var item = serializer.serializeItem(s, {name: {Exists: false}}, {expected : true}); - item.name.should.equal('Tim Tester'); + item.should.eql({name: { 'Exists' : false}}); }); - it('should parse number attribute', function () { - schema.Number('age'); - - var itemResp = {age : {N: '18'} }; + it('should serialize nested attributes', function () { + var config = { + hashKey: 'name', + schema : { + name : Joi.string(), + data : { + first : Joi.string(), + flag : Joi.boolean(), + nicks : Schema.types.stringSet(), + }, + } + }; - var item = serializer.deserializeItem(schema, itemResp); + var s = new Schema(config); - item.age.should.equal(18); - }); + var d = { + name: 'Foo Bar', + data : { first : 'Test', flag : true, nicks : ['a', 'b', 'c']} + }; - it('should parse binary attribute', function () { - schema.Binary('data'); + var item = serializer.serializeItem(s, d); - var itemResp = {data : {B: 'aGVsbG8='} }; + item.name.should.eql('Foo Bar'); + item.data.first.should.eql('Test'); + item.data.flag.should.eql(true); - var item = serializer.deserializeItem(schema, itemResp); + var stringSet = docClient.Set(['a', 'b', 'c'], 'S'); - item.data.toString().should.equal('hello'); + item.data.nicks.datatype.should.eql('SS'); + item.data.nicks.contents.should.eql(stringSet.contents); }); - it('should parse compressed binary data', function (done) { - schema.Binary('data'); - - var itemResp = {data : {B: 'eJzT0yMAAGTvBe8='} }; - - var item = serializer.deserializeItem(schema, itemResp); - zlib.unzip(item.data, function(err, buffer) { - if (!err) { - try { - buffer.toString().should.equal('.................................'); - done(); - } catch(e) { - done(e); - return; - } + it('should return empty when serializing null value', function () { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + names : Schema.types.stringSet(), } - }); - - }); - - it('should parse number attribute', function () { - schema.Date('created'); - - var itemResp = {created : {S: '2013-05-15T21:47:28.479Z'} }; + }; - var item = serializer.deserializeItem(schema, itemResp); + var s = new Schema(config); - item.created.should.eql(new Date('2013-05-15T21:47:28.479Z')); - }); - - it('should parse boolean attribute', function () { - schema.Boolean('agree'); - - serializer.deserializeItem(schema, {agree: {N: '1'}}).agree.should.be.true; - serializer.deserializeItem(schema, {agree: {N: '0'}}).agree.should.be.false; + var item = serializer.serializeItem(s, {names: null}); - serializer.deserializeItem(schema, {agree: {S: 'true'}}).agree.should.be.true; - serializer.deserializeItem(schema, {agree: {S: 'false'}}).agree.should.be.false; + item.should.eql({}); }); - it('should parse string set attribute', function () { - schema.StringSet('names'); + }); - var itemResp = {names : {SS: ['Bob', 'Joe', 'Tim']} }; + describe('#deserializeItem', function () { + it('should return string value', function () { + var itemResp = {name : 'Tim Tester' }; - var item = serializer.deserializeItem(schema, itemResp); + var item = serializer.deserializeItem(itemResp); - item.names.should.eql(['Bob', 'Joe', 'Tim']); + item.name.should.equal('Tim Tester'); }); - it('should parse number set attribute', function () { - schema.NumberSet('nums'); - - var itemResp = {nums : {NS: ['18', '22', '23']} }; + it('should return values in StringSet', function () { + var itemResp = {names : docClient.Set(['a', 'b', 'c'], 'S')}; - var item = serializer.deserializeItem(schema, itemResp); + var item = serializer.deserializeItem(itemResp); - item.nums.should.eql([18, 22, 23]); + item.names.should.eql(['a', 'b', 'c']); }); - it('should parse binary set attribute', function (done) { - schema.BinarySet('data'); - - var test = ['hello', 'world']; - var i = 0; + it('should return values in NumberSet', function () { + var itemResp = {scores : docClient.Set([1, 2, 3], 'N')}; - var itemResp = {data : {BS: ['aGVsbG8=', 'd29ybGQ=']} }; + var item = serializer.deserializeItem(itemResp); - var item = serializer.deserializeItem(schema, itemResp); - - async.forEachSeries(item.data, function(value, callback) { - try { - value.toString().should.equal(test[i++]); - } catch(err) { - return callback(err); - } - callback(); - }, done); + item.scores.should.eql([1, 2, 3]); }); - it('should return null', function () { - schema.String('email'); - schema.NumberSet('nums'); - - var item = serializer.deserializeItem(schema, null); + it('should return null when item is null', function () { + var item = serializer.deserializeItem(null); expect(item).to.be.null; }); - it('should parse uuid attribute', function () { - schema.UUID('id'); - - var itemResp = {id : {S: '1234-5678-9012'} }; - - var item = serializer.deserializeItem(schema, itemResp); - - item.id.should.equal('1234-5678-9012'); - }); - - it('should parse time uuid attribute', function () { - schema.TimeUUID('stamp'); - - var itemResp = {stamp : {S: '1234-5678-9012'} }; - - var item = serializer.deserializeItem(schema, itemResp); - - item.stamp.should.equal('1234-5678-9012'); - }); - - it('should omit attributes with null values', function () { - schema.String('name'); - schema.String('title'); - - var itemResp = {name : {S: 'Tim Tester'} }; + it('should return nested values', function () { + var itemResp = { + name : 'foo bar', + scores : docClient.Set([1, 2, 3], 'N'), + things : [{ + title : 'item 1', + letters : docClient.Set(['a', 'b', 'c'], 'S') + }, { + title : 'item 2', + letters : docClient.Set(['x', 'y', 'z'], 'S') + }], + info : { + name : 'baz', + ages : docClient.Set([20, 21, 22], 'N') + } + }; - var item = serializer.deserializeItem(schema, itemResp); + var item = serializer.deserializeItem(itemResp); - expect(item).to.include.keys('name'); - expect(item).to.not.include.keys('title'); + item.should.eql({ + name : 'foo bar', + scores : [1, 2, 3], + things : [{ + title : 'item 1', + letters : ['a', 'b', 'c'] + }, { + title : 'item 2', + letters : ['x', 'y', 'z'] + }], + info : { + name : 'baz', + ages : [20, 21, 22] + } + }); }); - }); describe('#serializeItemForUpdate', function () { it('should serialize string attribute', function () { - schema.String('name'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + name : Joi.string(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItemForUpdate(schema, 'PUT', {name: 'Tim Tester'}); + var item = serializer.serializeItemForUpdate(s, 'PUT', {name: 'Tim Tester'}); - item.should.eql({ name: {Action: 'PUT', Value: {S: 'Tim Tester'} }}); + item.should.eql({ name: {Action: 'PUT', Value: 'Tim Tester'}}); }); it('should serialize number attribute', function () { - schema.Number('age'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + age : Joi.number(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItemForUpdate(schema, 'PUT', {age: 25}); + var item = serializer.serializeItemForUpdate(s, 'PUT', {age: 25}); - item.should.eql({ age: {Action: 'PUT', Value: {N: '25'} }}); + item.should.eql({ age: {Action: 'PUT', Value: 25}}); }); it('should serialize three attributes', function () { - schema.String('name'); - schema.Number('age'); - schema.NumberSet('scores'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + name : Joi.string(), + age : Joi.number(), + scores : Schema.types.numberSet(), + } + }; + + var s = new Schema(config); var attr = {name: 'Tim Test', age: 25, scores: [94, 92, 100]}; - var item = serializer.serializeItemForUpdate(schema, 'PUT', attr); + var item = serializer.serializeItemForUpdate(s, 'PUT', attr); - item.should.eql({ - name : {Action : 'PUT', Value : {S : 'Tim Test'}}, - age : {Action : 'PUT', Value : {N : '25'} }, - scores : {Action : 'PUT', Value : {NS : ['94', '92', '100']} } - }); + item.name.should.eql({Action : 'PUT', Value : 'Tim Test'}); + item.age.should.eql({Action : 'PUT', Value : 25}); + + var numberSet = docClient.Set([94, 92, 100], 'N'); + item.scores.Action.should.eql('PUT'); + item.scores.Value.datatype.should.eql('NS'); + item.scores.Value.contents.should.eql(numberSet.contents); }); it('should serialize null value to a DELETE action', function () { - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'foo', + schema : { + foo : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } + }; - var item = serializer.serializeItemForUpdate(schema, 'PUT', {age: null, name : 'Foo Bar'}); + var s = new Schema(config); + + var item = serializer.serializeItemForUpdate(s, 'PUT', {age: null, name : 'Foo Bar'}); item.should.eql({ - name: {Action: 'PUT', Value: {S: 'Foo Bar'} }, + name: {Action: 'PUT', Value: 'Foo Bar' }, age: {Action: 'DELETE'} }); }); it('should not serialize hashkey attribute', function () { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; - var item = serializer.serializeItemForUpdate(schema, 'PUT', {email: 'test@test.com', name: 'Tim Tester'}); + var s = new Schema(config); - item.should.eql({ name: {Action: 'PUT', Value: {S: 'Tim Tester'} }}); + var item = serializer.serializeItemForUpdate(s, 'PUT', {email: 'test@test.com', name: 'Tim Tester'}); + + item.should.eql({ name: {Action: 'PUT', Value: 'Tim Tester' }}); }); it('should not serialize hashkey and rangeKey attributes', function () { - schema.String('email', {hashKey: true}); - schema.String('range', {rangeKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + rangeKey: 'range', + schema : { + email : Joi.string(), + range : Joi.string(), + name : Joi.string(), + } + }; + + var s = new Schema(config); - var item = serializer.serializeItemForUpdate(schema, 'PUT', {email: 'test@test.com', range: 'FOO', name: 'Tim Tester'}); + var item = serializer.serializeItemForUpdate(s, 'PUT', {email: 'test@test.com', range: 'FOO', name: 'Tim Tester'}); - item.should.eql({ name: {Action: 'PUT', Value: {S: 'Tim Tester'} }}); + item.should.eql({ name: {Action: 'PUT', Value: 'Tim Tester'}}); }); it('should serialize add operations', function () { - schema.String('email', {hashKey: true}); - schema.Number('age'); - schema.StringSet('names'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + age : Joi.number(), + names : Schema.types.stringSet(), + } + }; + + var s = new Schema(config); var update = {email: 'test@test.com', age: {$add : 1}, names : {$add: ['foo', 'bar']}}; - var item = serializer.serializeItemForUpdate(schema, 'PUT', update); + var item = serializer.serializeItemForUpdate(s, 'PUT', update); - item.should.eql({ - age : {Action: 'ADD', Value: {N: '1'}}, - names: {Action: 'ADD', Value: {SS: ['foo', 'bar']}} - }); + item.age.should.eql({Action: 'ADD', Value: 1}); + + var stringSet = docClient.Set(['foo', 'bar'], 'S'); + item.names.Action.should.eql('ADD'); + item.names.Value.datatype.should.eql('SS'); + item.names.Value.contents.should.eql(stringSet.contents); }); it('should serialize delete operations', function () { - schema.String('email', {hashKey: true}); - schema.StringSet('names'); - schema.NumberSet('ages'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + names : Schema.types.stringSet(), + ages : Schema.types.numberSet(), + } + }; + + var s = new Schema(config); var update = {email: 'test@test.com', ages: {$del : [2, 3]}, names : {$del: ['foo', 'bar']}}; - var item = serializer.serializeItemForUpdate(schema, 'PUT', update); + var item = serializer.serializeItemForUpdate(s, 'PUT', update); + + var stringSet = docClient.Set(['foo', 'bar'], 'S'); + item.names.Action.should.eql('DELETE'); + item.names.Value.datatype.should.eql('SS'); + item.names.Value.contents.should.eql(stringSet.contents); + + var numberSet = docClient.Set([2, 3], 'N'); + item.ages.Action.should.eql('DELETE'); + item.ages.Value.datatype.should.eql('NS'); + item.ages.Value.contents.should.eql(numberSet.contents); - item.should.eql({ - names: {Action: 'DELETE', Value: {SS: ['foo', 'bar']}}, - ages : {Action: 'DELETE', Value: {NS: ['2', '3']}} - }); }); }); diff --git a/test/table-test.js b/test/table-test.js index ea797d8..ddfa276 100644 --- a/test/table-test.js +++ b/test/table-test.js @@ -2,52 +2,50 @@ var helper = require('./test-helper'), _ = require('lodash'), + Joi = require('joi'), Table = require('../lib/table'), Schema = require('../lib/schema'), Query = require('../lib//query'), Scan = require('../lib//scan'), Item = require('../lib/item'), + realSerializer = require('../lib/serializer'), chai = require('chai'), - expect = chai.expect; + expect = chai.expect, + sinon = require('sinon'); chai.should(); describe('table', function () { - var schema, - table, + var table, serializer, - dynamodb; + docClient; beforeEach(function () { - schema = new Schema(); serializer = helper.mockSerializer(), - dynamodb = helper.mockDynamoDB(); + docClient = helper.mockDocClient(); }); describe('#get', function () { it('should get item by hash key', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email' + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', - Key : { - email : {S : 'test@test.com'} - } + Key : { email : 'test@test.com'} }; var resp = { - Item : {email: {S : 'test@test.com'}, name: {S: 'test dude'}} + Item : {email: 'test@test.com', name: 'test dude'} }; - dynamodb.getItem.withArgs(request).yields(null, resp); - - serializer.buildKey.returns({email: resp.Item.email}); - - serializer.deserializeItem.withArgs(schema, resp.Item).returns({email : 'test@test.com', name : 'test dude'}); + docClient.getItem.withArgs(request).yields(null, resp); table.get('test@test.com', function (err, account) { account.should.be.instanceof(Item); @@ -59,34 +57,31 @@ describe('table', function () { }); it('should get item by hash and range key', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age'); + var config = { + hashKey: 'name', + rangeKey: 'email' + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', Key : { - name : {S : 'Tim Tester'}, - email : {S : 'test@test.com'} + name : 'Tim Tester', + email : 'test@test.com' } }; var resp = { - Item : {email: {S : 'test@test.com'}, name: {S: 'Tim Tester'}} + Item : {email: 'test@test.com', name: 'Tim Tester'} }; - dynamodb.getItem.withArgs(request).yields(null, resp); - - serializer.buildKey.returns({email: resp.Item.email, name : resp.Item.name}); - - serializer.deserializeItem.withArgs(schema, resp.Item).returns({email : 'test@test.com', name : 'Tim Tester'}); + docClient.getItem.withArgs(request).yields(null, resp); table.get('Tim Tester', 'test@test.com', function (err, account) { account.should.be.instanceof(Item); - serializer.buildKey.calledWith('Tim Tester', 'test@test.com', schema).should.be.true; - account.get('email').should.equal('test@test.com'); account.get('name').should.equal('Tim Tester'); @@ -95,28 +90,25 @@ describe('table', function () { }); it('should get item by hash key and options', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', - Key : { - email : {S : 'test@test.com'} - }, + Key : { email : 'test@test.com' }, ConsistentRead: true }; var resp = { - Item : {email: {S : 'test@test.com'}, name: {S: 'test dude'}} + Item : {email: 'test@test.com', name: 'test dude'} }; - dynamodb.getItem.withArgs(request).yields(null, resp); - - serializer.buildKey.returns({email: resp.Item.email}); - - serializer.deserializeItem.withArgs(schema, resp.Item).returns({email : 'test@test.com', name : 'test dude'}); + docClient.getItem.withArgs(request).yields(null, resp); table.get('test@test.com', {ConsistentRead: true}, function (err, account) { account.should.be.instanceof(Item); @@ -128,35 +120,32 @@ describe('table', function () { }); it('should get item by hashkey, range key and options', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age'); + var config = { + hashKey: 'name', + rangeKey: 'email', + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', Key : { - name : {S : 'Tim Tester'}, - email : {S : 'test@test.com'} + name : 'Tim Tester', + email : 'test@test.com' }, ConsistentRead: true }; var resp = { - Item : {email: {S : 'test@test.com'}, name: {S: 'Tim Tester'}} + Item : {email: 'test@test.com', name: 'Tim Tester'} }; - dynamodb.getItem.withArgs(request).yields(null, resp); - - serializer.buildKey.returns({email: resp.Item.email, name : resp.Item.name}); - - serializer.deserializeItem.withArgs(schema, resp.Item).returns({email : 'test@test.com', name : 'Tim Tester'}); + docClient.getItem.withArgs(request).yields(null, resp); table.get('Tim Tester', 'test@test.com', {ConsistentRead: true}, function (err, account) { account.should.be.instanceof(Item); - serializer.buildKey.calledWith('Tim Tester', 'test@test.com', schema).should.be.true; - account.get('email').should.equal('test@test.com'); account.get('name').should.equal('Tim Tester'); @@ -165,30 +154,28 @@ describe('table', function () { }); it('should get item from dynamic table by hash key', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.tableName = function () { - return 'accounts_2014'; + + var config = { + hashKey: 'email', + tableName : function () { + return 'accounts_2014'; + } }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts_2014', - Key : { - email : {S : 'test@test.com'} - } + Key : { email : 'test@test.com' } }; var resp = { - Item : {email: {S : 'test@test.com'}, name: {S: 'test dude'}} + Item : {email: 'test@test.com', name: 'test dude'} }; - dynamodb.getItem.withArgs(request).yields(null, resp); - - serializer.buildKey.returns({email: resp.Item.email}); - - serializer.deserializeItem.withArgs(schema, resp.Item).returns({email : 'test@test.com', name : 'test dude'}); + docClient.getItem.withArgs(request).yields(null, resp); table.get('test@test.com', function (err, account) { account.should.be.instanceof(Item); @@ -198,32 +185,56 @@ describe('table', function () { done(); }); }); + + it('should return error', function (done) { + var config = { + hashKey: 'email', + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + docClient.getItem.yields(new Error('Fail')); + + table.get('test@test.com', function (err, account) { + expect(err).to.exist; + expect(account).to.not.exist; + done(); + }); + }); + }); describe('#create', function () { it('should create valid item', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', Item : { - email : {S : 'test@test.com'}, - name : {S : 'Tim Test'}, - age : {N : '23'} + email : 'test@test.com', + name : 'Tim Test', + age : 23 } }; - var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - dynamodb.putItem.withArgs(request).yields(null, {}); - - serializer.serializeItem.withArgs(schema, item).returns(request.Item); + docClient.putItem.withArgs(request).yields(null, {}); - table.create(item, function (err, account) { + table.create(request.Item, function (err, account) { + expect(err).to.not.exist; account.should.be.instanceof(Item); account.get('email').should.equal('test@test.com'); @@ -234,27 +245,32 @@ describe('table', function () { }); it('should call apply defaults', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name', {default: 'Foo'}); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string().default('Foo'), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', Item : { - email : {S : 'test@test.com'}, - name : {S : 'Foo'}, - age : {N : '23'} + email : 'test@test.com', + name : 'Foo', + age : 23 } }; - var item = {email : 'test@test.com', name : 'Foo', age : 23}; - dynamodb.putItem.withArgs(request).yields(null, {}); - - serializer.serializeItem.withArgs(schema, item).returns(request.Item); + docClient.putItem.withArgs(request).yields(null, {}); table.create({email : 'test@test.com', age: 23}, function (err, account) { + expect(err).to.not.exist; account.should.be.instanceof(Item); account.get('email').should.equal('test@test.com'); @@ -265,28 +281,42 @@ describe('table', function () { }); it('should omit null values', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age').allow(null); - schema.NumberSet('favoriteNumbers').allow(null); - schema.NumberSet('luckyNumbers').allow(null); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number().allow(null), + favoriteNumbers : Schema.types.numberSet().allow(null), + luckyNumbers : Schema.types.numberSet().allow(null) + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); + + var numberSet = sinon.match(function (value) { + var s = docClient.Set([1, 2, 3], 'N'); + + value.datatype.should.eql('NS'); + value.contents.should.eql(s.contents); + + return true; + }, 'NumberSet'); var request = { TableName: 'accounts', Item : { - email : {S : 'test@test.com'}, - name : {S : 'Tim Test'}, - luckyNumbers: {NS: [1, 2, 3]} + email : 'test@test.com', + name : 'Tim Test', + luckyNumbers: numberSet } }; - var item = {email : 'test@test.com', name : 'Tim Test', age : null, favoriteNumbers: [], luckyNumbers: [1, 2, 3]}; - dynamodb.putItem.withArgs(request).yields(null, {}); - - serializer.serializeItem.withArgs(schema, {email : 'test@test.com', name : 'Tim Test', luckyNumbers: [1, 2, 3]}).returns(request.Item); + docClient.putItem.withArgs(request).yields(null, {}); + var item = {email : 'test@test.com', name : 'Tim Test', age : null, favoriteNumbers: [], luckyNumbers: [1, 2, 3]}; table.create(item, function (err, account) { account.should.be.instanceof(Item); @@ -300,46 +330,161 @@ describe('table', function () { }); }); + it('should create item with createdAt timestamp', function (done) { + var config = { + hashKey: 'email', + timestamps : true, + schema : { + email : Joi.string(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Item : { + email : 'test@test.com', + createdAt : sinon.match.string + } + }; + + docClient.putItem.withArgs(request).yields(null, {}); + + table.create({email : 'test@test.com'}, function (err, account) { + expect(err).to.not.exist; + account.should.be.instanceof(Item); + + account.get('email').should.equal('test@test.com'); + account.get('createdAt').should.exist; + done(); + }); + }); + + it('should create item with expected option', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string() + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Item : { + email : 'test@test.com', + }, + Expected : { + name : {Value : 'Foo Bar'} + } + }; + + docClient.putItem.withArgs(request).yields(null, {}); + + table.create({email : 'test@test.com'}, {expected: {name: 'Foo Bar'}}, function (err, account) { + expect(err).to.not.exist; + account.should.be.instanceof(Item); + + account.get('email').should.equal('test@test.com'); + done(); + }); + }); + + it('should create item with no callback', function (done) { + var config = { + hashKey: 'email', + timestamps : true, + schema : { + email : Joi.string(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Item : { + email : 'test@test.com', + } + }; + + docClient.putItem.withArgs(request).yields(null, {}); + + table.create({email : 'test@test.com'}); + + docClient.putItem.calledWith(request); + return done(); + }); + + it('should return validation error', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string() + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + table.create({email : 'test@test.com', name : [1, 2, 3]}, function (err, account) { + expect(err).to.exist; + expect(err).to.match(/ValidationError/); + expect(account).to.not.exist; + + sinon.assert.notCalled(docClient.putItem); + done(); + }); + }); + }); describe('#update', function () { it('should update valid item', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', - Key : { - email : {S : 'test@test.com'} - }, - AttributeUpdates : { - email : {Action : 'PUT', Value: {S : 'test@test.com'}}, - name : {Action : 'PUT', Value: {S : 'Tim Test'}}, - age : {Action : 'PUT', Value: {N : '23'}} - }, - ReturnValues: 'ALL_NEW' + Key : { email : 'test@test.com'}, + ReturnValues: 'ALL_NEW', + UpdateExpression : 'SET #name = :name, #age = :age', + ExpressionAttributeValues : { ':name' : 'Tim Test', ':age' : 23}, + ExpressionAttributeNames : { '#name' : 'name', '#age' : 'age'} }; var returnedAttributes = { - email : {S : 'test@test.com'}, - name : {S : 'Tim Test'}, - age : {N : '25'}, - scores : {NS : ['97', '86']} + email : 'test@test.com', + name : 'Tim Test', + age : 23, + scores : [97, 86] }; - var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - - serializer.buildKey.returns(request.Key); - serializer.serializeItemForUpdate.withArgs(schema, 'PUT', item).returns(request.AttributeUpdates); - - var returnedItem = _.merge({}, item, {scores: [97, 86]}); - serializer.deserializeItem.withArgs(schema, returnedAttributes).returns(returnedItem); - dynamodb.updateItem.withArgs(request).yields(null, {Attributes: returnedAttributes}); + docClient.updateItem.withArgs(request).yields(null, {Attributes: returnedAttributes}); + var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; table.update(item, function (err, account) { account.should.be.instanceof(Item); @@ -353,68 +498,189 @@ describe('table', function () { }); it('should update with passed in options', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } + }; - schema.globalIndex('AgeIndex', { hashKey: 'age', rangeKey: 'name'}); + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, realSerializer, docClient); var request = { TableName: 'accounts', - Key : { - email : {S : 'test@test.com'} - }, - AttributeUpdates : { - email : {Action : 'PUT', Value: {S : 'test@test.com'}}, - name : {Action : 'PUT', Value: {S : 'Tim Test'}}, - age : {Action : 'PUT', Value: {N : '23'}} - }, + Key : { email : 'test@test.com' }, ReturnValues: 'ALL_OLD', Expected : { - name : {'Value' : {S : 'Foo Bar'}} - } + name : {'Value' : 'Foo Bar'} + }, + UpdateExpression : 'SET #name = :name, #age = :age', + ExpressionAttributeValues : { ':name' : 'Tim Test', ':age' : 23}, + ExpressionAttributeNames : { '#name' : 'name', '#age' : 'age'} }; var returnedAttributes = { - email : {S : 'test@test.com'}, - name : {S : 'Tim Test'}, - age : {N : '25'}, - scores : {NS : ['97', '86']} + email : 'test@test.com', + name : 'Tim Test', + age : 23, + scores : [97, 86] }; var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - serializer.buildKey.withArgs('test@test.com', null, schema).returns(request.Key); - serializer.serializeItemForUpdate.withArgs(schema, 'PUT', item).returns(request.AttributeUpdates); + docClient.updateItem.withArgs(request).yields(null, {Attributes: returnedAttributes}); - var returnedItem = _.merge({}, item, {scores: [97, 86]}); - serializer.deserializeItem.withArgs(schema, returnedAttributes).returns(returnedItem); - dynamodb.updateItem.withArgs(request).yields(null, {Attributes: returnedAttributes}); + table.update(item, {ReturnValues: 'ALL_OLD', expected: {name: 'Foo Bar'}}, function (err, account) { + account.should.be.instanceof(Item); - serializer.serializeItem.withArgs(schema, {name: 'Foo Bar'}, {expected: true}).returns(request.Expected); + account.get('email').should.equal('test@test.com'); + account.get('name').should.equal('Tim Test'); + account.get('age').should.equal(23); + account.get('scores').should.eql([97, 86]); - table.update(item, {ReturnValues: 'ALL_OLD', expected: {name: 'Foo Bar'}}, function (err, account) { + done(); + }); + }); + + it('should update merge update expressions when passed in as options', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Key : { email : 'test@test.com' }, + ReturnValues: 'ALL_NEW', + UpdateExpression : 'SET #name = :name, #age = :age ADD #color :c', + ExpressionAttributeValues : { ':name' : 'Tim Test', ':age' : 23, ':c' : 'red'}, + ExpressionAttributeNames : { '#name' : 'name', '#age' : 'age', '#color' : 'color'} + }; + + var returnedAttributes = { + email : 'test@test.com', + name : 'Tim Test', + age : 23, + scores : [97, 86], + color : 'red' + }; + + var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; + + docClient.updateItem.withArgs(request).yields(null, {Attributes: returnedAttributes}); + + var options = { + UpdateExpression : 'ADD #color :c', + ExpressionAttributeValues : { ':c' : 'red'}, + ExpressionAttributeNames : { '#color' : 'color'} + }; + + table.update(item, options, function (err, account) { account.should.be.instanceof(Item); account.get('email').should.equal('test@test.com'); account.get('name').should.equal('Tim Test'); account.get('age').should.equal(23); account.get('scores').should.eql([97, 86]); + account.get('color').should.eql('red'); + + done(); + }); + }); + + it('should update valid item without a callback', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Key : { email : 'test@test.com'}, + ReturnValues: 'ALL_NEW', + UpdateExpression : 'SET #name = :name, #age = :age', + ExpressionAttributeValues : { ':name' : 'Tim Test', ':age' : 23}, + ExpressionAttributeNames : { '#name' : 'name', '#age' : 'age'} + }; + + var returnedAttributes = { + email : 'test@test.com', + name : 'Tim Test', + age : 23, + scores : [97, 86] + }; + + docClient.updateItem.withArgs(request).yields(null, {Attributes: returnedAttributes}); + + var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; + table.update(item); + + docClient.updateItem.calledWith(request); + return done(); + }); + + it('should return error', function (done) { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + docClient.updateItem.yields(new Error('Fail')); + + var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; + table.update(item, function (err, account) { + expect(err).to.exist; + expect(account).to.not.exist; done(); }); }); + }); describe('#query', function () { it('should return query object', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); table.query('Bob').should.be.instanceof(Query); }); @@ -423,10 +689,18 @@ describe('table', function () { describe('#scan', function () { it('should return scan object', function () { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); table.scan().should.be.instanceof(Scan); }); @@ -435,37 +709,51 @@ describe('table', function () { describe('#destroy', function () { it('should destroy valid item', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', Key : { - email : {S : 'test@test.com'} + email : 'test@test.com' } }; - dynamodb.deleteItem.yields(null, {}); + docClient.deleteItem.yields(null, {}); serializer.buildKey.returns(request.Key); table.destroy('test@test.com', function () { - serializer.buildKey.calledWith('test@test.com', null, schema).should.be.true; - dynamodb.deleteItem.calledWith(request).should.be.true; + serializer.buildKey.calledWith('test@test.com', null, s).should.be.true; + docClient.deleteItem.calledWith(request).should.be.true; done(); }); }); it('should take optional params', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', @@ -475,48 +763,53 @@ describe('table', function () { ReturnValues : 'ALL_OLD' }; - dynamodb.deleteItem.yields(null, {}); + docClient.deleteItem.yields(null, {}); serializer.buildKey.returns(request.Key); table.destroy('test@test.com', {ReturnValues: 'ALL_OLD'}, function () { - serializer.buildKey.calledWith('test@test.com', null, schema).should.be.true; - dynamodb.deleteItem.calledWith(request).should.be.true; + serializer.buildKey.calledWith('test@test.com', null, s).should.be.true; + docClient.deleteItem.calledWith(request).should.be.true; done(); }); }); it('should parse and return attributes', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', - Key : { - email : {S : 'test@test.com'} - }, + Key : { email : 'test@test.com' }, ReturnValues : 'ALL_OLD' }; var returnedAttributes = { - email : {S : 'test@test.com'}, - name : {S : 'Foo Bar'} + email : 'test@test.com', + name : 'Foo Bar' }; - dynamodb.deleteItem.yields(null, {Attributes: returnedAttributes}); + docClient.deleteItem.yields(null, {Attributes: returnedAttributes}); serializer.buildKey.returns(request.Key); - serializer.deserializeItem.withArgs(schema, returnedAttributes).returns( + serializer.deserializeItem.withArgs(returnedAttributes).returns( {email : 'test@test.com', name: 'Foo Bar' }); table.destroy('test@test.com', {ReturnValues: 'ALL_OLD'}, function (err, item) { - serializer.buildKey.calledWith('test@test.com', null, schema).should.be.true; - dynamodb.deleteItem.calledWith(request).should.be.true; + serializer.buildKey.calledWith('test@test.com', null, s).should.be.true; + docClient.deleteItem.calledWith(request).should.be.true; item.get('name').should.equal('Foo Bar'); @@ -525,35 +818,43 @@ describe('table', function () { }); it('should accept hash and range key', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name', {rangeKey: true}); - schema.Number('age'); + var config = { + hashKey: 'email', + rangeKey: 'name', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', Key : { - email : {S : 'test@test.com'}, - name : {S : 'Foo Bar'} + email : 'test@test.com', + name : 'Foo Bar' } }; var returnedAttributes = { - email : {S : 'test@test.com'}, - name : {S : 'Foo Bar'} + email : 'test@test.com', + name : 'Foo Bar' }; - dynamodb.deleteItem.yields(null, {Attributes: returnedAttributes}); + docClient.deleteItem.yields(null, {Attributes: returnedAttributes}); serializer.buildKey.returns(request.Key); - serializer.deserializeItem.withArgs(schema, returnedAttributes).returns( + serializer.deserializeItem.withArgs(returnedAttributes).returns( {email : 'test@test.com', name: 'Foo Bar' }); table.destroy('test@test.com', 'Foo Bar', function (err, item) { - serializer.buildKey.calledWith('test@test.com', 'Foo Bar', schema).should.be.true; - dynamodb.deleteItem.calledWith(request).should.be.true; + serializer.buildKey.calledWith('test@test.com', 'Foo Bar', s).should.be.true; + docClient.deleteItem.calledWith(request).should.be.true; item.get('name').should.equal('Foo Bar'); @@ -562,36 +863,44 @@ describe('table', function () { }); it('should accept hashkey rangekey and options', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name', {rangeKey: true}); - schema.Number('age'); + var config = { + hashKey: 'email', + rangeKey: 'name', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', Key : { - email : {S : 'test@test.com'}, - name : {S : 'Foo Bar'} + email : 'test@test.com', + name : 'Foo Bar' }, ReturnValues : 'ALL_OLD' }; var returnedAttributes = { - email : {S : 'test@test.com'}, - name : {S : 'Foo Bar'} + email : 'test@test.com', + name : 'Foo Bar' }; - dynamodb.deleteItem.yields(null, {Attributes: returnedAttributes}); + docClient.deleteItem.yields(null, {Attributes: returnedAttributes}); serializer.buildKey.returns(request.Key); - serializer.deserializeItem.withArgs(schema, returnedAttributes).returns( + serializer.deserializeItem.withArgs(returnedAttributes).returns( {email : 'test@test.com', name: 'Foo Bar' }); table.destroy('test@test.com', 'Foo Bar', {ReturnValues: 'ALL_OLD'}, function (err, item) { - serializer.buildKey.calledWith('test@test.com', 'Foo Bar', schema).should.be.true; - dynamodb.deleteItem.calledWith(request).should.be.true; + serializer.buildKey.calledWith('test@test.com', 'Foo Bar', s).should.be.true; + docClient.deleteItem.calledWith(request).should.be.true; item.get('name').should.equal('Foo Bar'); @@ -600,42 +909,117 @@ describe('table', function () { }); it('should serialize expected option', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', Key : { - email : {S : 'test@test.com'} + email : 'test@test.com' }, Expected : { - name : {'Value' : {S : 'Foo Bar'}} + name : {'Value' : 'Foo Bar'} } }; - dynamodb.deleteItem.yields(null, {}); + docClient.deleteItem.yields(null, {}); - serializer.serializeItem.withArgs(schema, {name: 'Foo Bar'}, {expected : true}).returns(request.Expected); + serializer.serializeItem.withArgs(s, {name: 'Foo Bar'}, {expected : true}).returns(request.Expected); serializer.buildKey.returns(request.Key); table.destroy('test@test.com', {expected: {name : 'Foo Bar'}}, function () { - serializer.buildKey.calledWith('test@test.com', null, schema).should.be.true; - dynamodb.deleteItem.calledWith(request).should.be.true; + serializer.buildKey.calledWith('test@test.com', null, s).should.be.true; + docClient.deleteItem.calledWith(request).should.be.true; done(); }); }); + + it('should call delete item without callback', function (done) { + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Key : { + email : 'test@test.com' + } + }; + + docClient.deleteItem.yields(null, {}); + table.destroy('test@test.com'); + + docClient.deleteItem.calledWith(request); + + return done(); + }); + + it('should call delete item with hash key, options and no callback', function (done) { + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, realSerializer, docClient); + + var request = { + TableName: 'accounts', + Key : { + email : 'test@test.com' + }, + Expected : { + name : {'Value' : 'Foo Bar'} + } + }; + + docClient.deleteItem.yields(null, {}); + table.destroy('test@test.com', {expected: {name : 'Foo Bar'}}); + + docClient.deleteItem.calledWith(request); + + return done(); + }); }); describe('#createTable', function () { it('should create table with hash key', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', @@ -648,21 +1032,29 @@ describe('table', function () { ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } }; - dynamodb.createTable.yields(null, {}); + docClient.createTable.yields(null, {}); table.createTable({readCapacity : 5, writeCapacity: 5}, function (err) { expect(err).to.be.null; - dynamodb.createTable.calledWith(request).should.be.true; + docClient.createTable.calledWith(request).should.be.true; done(); }); }); it('should create table with range key', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + schema : { + name : Joi.string(), + email : Joi.string(), + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', @@ -677,22 +1069,33 @@ describe('table', function () { ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } }; - dynamodb.createTable.yields(null, {}); + docClient.createTable.yields(null, {}); table.createTable({readCapacity : 5, writeCapacity: 5}, function (err) { expect(err).to.be.null; - dynamodb.createTable.calledWith(request).should.be.true; + docClient.createTable.calledWith(request).should.be.true; done(); }); }); it('should create table with secondary index', function (done) { - schema.String('name', {hashKey: true}); - schema.String('email', {rangeKey: true}); - schema.Number('age', {secondaryIndex: true}); + var config = { + hashKey: 'name', + rangeKey: 'email', + indexes : [ + { hashKey : 'name', rangeKey : 'age', name : 'ageIndex', type : 'local' } + ], + schema : { + name : Joi.string(), + email : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts', @@ -720,23 +1123,32 @@ describe('table', function () { ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } }; - dynamodb.createTable.yields(null, {}); + docClient.createTable.yields(null, {}); table.createTable({readCapacity : 5, writeCapacity: 5}, function (err) { expect(err).to.be.null; - dynamodb.createTable.calledWith(request).should.be.true; + docClient.createTable.calledWith(request).should.be.true; done(); }); }); it('should create table with global secondary index', function (done) { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); + var config = { + hashKey: 'userId', + rangeKey: 'gameTitle', + indexes : [ + { hashKey : 'gameTitle', rangeKey : 'topScore', name : 'GameTitleIndex', type : 'global' } + ], + schema : { + userId : Joi.string(), + gameTitle : Joi.string(), + topScore : Joi.number() + } + }; - schema.globalIndex('GameTitleIndex', {hashKey: 'gameTitle', rangeKey : 'topScore'}); + var s = new Schema(config); - table = new Table('gameScores', schema, serializer, dynamodb); + table = new Table('gameScores', s, serializer, docClient); var request = { TableName: 'gameScores', @@ -765,29 +1177,38 @@ describe('table', function () { ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } }; - dynamodb.createTable.yields(null, {}); + docClient.createTable.yields(null, {}); table.createTable({readCapacity : 5, writeCapacity: 5}, function (err) { expect(err).to.be.null; - dynamodb.createTable.calledWith(request).should.be.true; + docClient.createTable.calledWith(request).should.be.true; done(); }); }); it('should create table with global secondary index', function (done) { - schema.String('userId', {hashKey: true}); - schema.String('gameTitle', {rangeKey: true}); - schema.Number('topScore'); - - schema.globalIndex('GameTitleIndex', { - hashKey: 'gameTitle', - rangeKey : 'topScore', - readCapacity: 10, - writeCapacity: 5, - Projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } - }); + var config = { + hashKey: 'userId', + rangeKey: 'gameTitle', + indexes : [{ + hashKey : 'gameTitle', + rangeKey : 'topScore', + name : 'GameTitleIndex', + type : 'global', + readCapacity : 10, + writeCapacity : 5, + projection: { NonKeyAttributes: [ 'wins' ], ProjectionType: 'INCLUDE' } + }], + schema : { + userId : Joi.string(), + gameTitle : Joi.string(), + topScore : Joi.number() + } + }; - table = new Table('gameScores', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('gameScores', s, serializer, docClient); var request = { TableName: 'gameScores', @@ -817,11 +1238,11 @@ describe('table', function () { ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } }; - dynamodb.createTable.yields(null, {}); + docClient.createTable.yields(null, {}); table.createTable({readCapacity : 5, writeCapacity: 5}, function (err) { expect(err).to.be.null; - dynamodb.createTable.calledWith(request).should.be.true; + docClient.createTable.calledWith(request).should.be.true; done(); }); }); @@ -830,20 +1251,27 @@ describe('table', function () { describe('#describeTable', function () { it('should make describe table request', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var request = { TableName: 'accounts' }; - dynamodb.describeTable.yields(null, {}); + docClient.describeTable.yields(null, {}); table.describeTable(function (err) { expect(err).to.be.null; - dynamodb.describeTable.calledWith(request).should.be.true; + docClient.describeTable.calledWith(request).should.be.true; done(); }); }); @@ -852,60 +1280,147 @@ describe('table', function () { describe('#updateTable', function () { - it('should make update table request', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); + beforeEach(function () { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); + }); + it('should make update table request', function (done) { var request = { TableName: 'accounts', ProvisionedThroughput: { ReadCapacityUnits: 4, WriteCapacityUnits: 2 } }; - dynamodb.updateTable.yields(null, {}); + docClient.updateTable.yields(null, {}); table.updateTable({readCapacity: 4, writeCapacity: 2}, function (err) { expect(err).to.be.null; - dynamodb.updateTable.calledWith(request).should.be.true; + docClient.updateTable.calledWith(request).should.be.true; done(); }); }); + + it('should make update table request without callback', function (done) { + var request = { + TableName: 'accounts', + ProvisionedThroughput: { ReadCapacityUnits: 2, WriteCapacityUnits: 1 } + }; + + table.updateTable({readCapacity: 2, writeCapacity: 1}); + + docClient.updateTable.calledWith(request).should.be.true; + + return done(); + }); + }); + + describe('#deleteTable', function () { + + beforeEach(function () { + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); + }); + + it('should make delete table request', function (done) { + var request = { + TableName: 'accounts' + }; + + docClient.deleteTable.yields(null, {}); + + table.deleteTable(function (err) { + expect(err).to.be.null; + docClient.deleteTable.calledWith(request).should.be.true; + done(); + }); + }); + + it('should make delete table request without callback', function (done) { + var request = { + TableName: 'accounts', + }; + + table.deleteTable(); + + docClient.deleteTable.calledWith(request).should.be.true; + + return done(); + }); }); describe('#tableName', function () { it('should return given name', function () { - schema.String('email', {hashKey: true}); - schema.String('name'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); table.tableName().should.eql('accounts'); }); it('should return table name set on schema', function () { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.tableName = 'accounts-2014-03'; + var config = { + hashKey: 'email', + tableName : 'accounts-2014-03', + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); table.tableName().should.eql('accounts-2014-03'); }); it('should return table name returned from function on schema', function () { - schema.String('email', {hashKey: true}); - schema.String('name'); - var d = new Date(); var dateString = [d.getFullYear(), d.getMonth() + 1].join('_'); - schema.tableName = function () { + var nameFunc = function () { return 'accounts_' + dateString; }; - table = new Table('accounts', schema, serializer, dynamodb); + var config = { + hashKey: 'email', + tableName : nameFunc, + schema : { + email : Joi.string(), + name : Joi.string(), + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); table.tableName().should.eql('accounts_' + dateString); }); @@ -916,17 +1431,25 @@ describe('table', function () { describe('#create', function () { - it('should call before hook', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + it('should call before hooks', function (done) { - table = new Table('accounts', schema, serializer, dynamodb); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - dynamodb.putItem.yields(null, {}); + docClient.putItem.yields(null, {}); - serializer.serializeItem.withArgs(schema, {email : 'test@test.com', name : 'Tommy', age : 23}).returns({}); + serializer.serializeItem.withArgs(s, {email : 'test@test.com', name : 'Tommy', age : 23}).returns({}); table.before('create', function (data, next) { expect(data).to.exist; @@ -935,20 +1458,35 @@ describe('table', function () { return next(null, data); }); + table.before('create', function (data, next) { + expect(data).to.exist; + data.age = '25'; + + return next(null, data); + }); + table.create(item, function (err, item) { expect(err).to.not.exist; item.get('name').should.equal('Tommy'); + item.get('age').should.equal('25'); return done(); }); }); it('should return error when before hook returns error', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); table.before('create', function (data, next) { return next(new Error('fail')); @@ -963,16 +1501,23 @@ describe('table', function () { }); it('should call after hook', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - dynamodb.putItem.yields(null, {}); + docClient.putItem.yields(null, {}); - serializer.serializeItem.withArgs(schema, item).returns({}); + serializer.serializeItem.withArgs(s, item).returns({}); table.after('create', function (data) { expect(data).to.exist; @@ -987,23 +1532,30 @@ describe('table', function () { describe('#update', function () { it('should call before hook', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - dynamodb.updateItem.yields(null, {}); + docClient.updateItem.yields(null, {}); - serializer.serializeItem.withArgs(schema, item).returns({}); + serializer.serializeItem.withArgs(s, item).returns({}); serializer.buildKey.returns({email: {S: 'test@test.com' }}); var modified = {email : 'test@test.com', name : 'Tim Test', age : 44}; - serializer.serializeItemForUpdate.withArgs(schema, 'PUT', modified).returns({}); + serializer.serializeItemForUpdate.withArgs(s, 'PUT', modified).returns({}); serializer.deserializeItem.returns(modified); - dynamodb.updateItem.yields(null, {}); + docClient.updateItem.yields(null, {}); var called = false; table.before('update', function (data, next) { @@ -1021,11 +1573,18 @@ describe('table', function () { }); it('should return error when before hook returns error', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; + + var s = new Schema(config); - table = new Table('accounts', schema, serializer, dynamodb); + table = new Table('accounts', s, serializer, docClient); table.before('update', function (data, next) { return next(new Error('fail')); @@ -1040,22 +1599,29 @@ describe('table', function () { }); it('should call after hook', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); + + table = new Table('accounts', s, serializer, docClient); var item = {email : 'test@test.com', name : 'Tim Test', age : 23}; - dynamodb.updateItem.yields(null, {}); + docClient.updateItem.yields(null, {}); - serializer.serializeItem.withArgs(schema, item).returns({}); + serializer.serializeItem.withArgs(s, item).returns({}); serializer.buildKey.returns({email: {S: 'test@test.com' }}); serializer.serializeItemForUpdate.returns({}); serializer.deserializeItem.returns(item); - dynamodb.updateItem.yields(null, {}); + docClient.updateItem.yields(null, {}); table.after('update', function () { @@ -1067,15 +1633,21 @@ describe('table', function () { }); it('#destroy should call after hook', function (done) { - schema.String('email', {hashKey: true}); - schema.String('name'); - schema.Number('age'); + var config = { + hashKey: 'email', + schema : { + email : Joi.string(), + name : Joi.string(), + age : Joi.number() + } + }; - table = new Table('accounts', schema, serializer, dynamodb); + var s = new Schema(config); - dynamodb.deleteItem.yields(null, {}); - serializer.buildKey.returns({}); + table = new Table('accounts', s, serializer, docClient); + docClient.deleteItem.yields(null, {}); + serializer.buildKey.returns({}); table.after('destroy', function () { return done(); diff --git a/test/test-helper.js b/test/test-helper.js index 025c7e6..615e23d 100644 --- a/test/test-helper.js +++ b/test/test-helper.js @@ -1,22 +1,38 @@ 'use strict'; var sinon = require('sinon'), - Table = require('../lib/table'); + AWS = require('aws-sdk'), + Table = require('../lib/table'), + DOC = require('dynamodb-doc'), + _ = require('lodash'); exports.mockDynamoDB = function () { - var dynamodb = { - scan : sinon.stub(), - putItem : sinon.stub(), - deleteItem : sinon.stub(), - query : sinon.stub(), - getItem : sinon.stub(), - updateItem : sinon.stub(), - createTable : sinon.stub(), - describeTable : sinon.stub(), - updateTable : sinon.stub() - }; + var opts = { endpoint : 'http://dynamodb-local:8000', apiVersion: '2012-08-10' }; + var db = new AWS.DynamoDB(opts); + + db.scan = sinon.stub(); + db.putItem = sinon.stub(); + db.deleteItem = sinon.stub(); + db.query = sinon.stub(); + db.getItem = sinon.stub(); + db.updateItem = sinon.stub(); + db.createTable = sinon.stub(); + db.describeTable = sinon.stub(); + db.updateTable = sinon.stub(); + db.deleteTable = sinon.stub(); + db.batchGetItem = sinon.stub(); + db.batchWriteItem = sinon.stub(); + + return db; +}; - return dynamodb; +exports.realDynamoDB = function () { + var opts = { endpoint : 'http://dynamodb-local:8000', apiVersion: '2012-08-10' }; + return new AWS.DynamoDB(opts); +}; + +exports.mockDocClient = function () { + return new DOC.DynamoDB(exports.mockDynamoDB()); }; exports.mockSerializer = function () { @@ -42,3 +58,7 @@ exports.fakeUUID = function () { return uuid; }; + +exports.randomName = function (prefix) { + return prefix + '_' + Date.now() + '.' + _.random(1000); +}; diff --git a/test/types/binary-test.js b/test/types/binary-test.js deleted file mode 100644 index 1d937d7..0000000 --- a/test/types/binary-test.js +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -var binaryType = require('../../lib/types/binary').create, - chai = require('chai'), - expect = chai.expect, - zlib = require('zlib'); - -chai.should(); - -describe('Binary Type', function () { - - it('should return null for buffer object', function (done) { - var d = binaryType().required(), - input = '.................................'; - - zlib.deflate(input, function(err, buffer) { - if (!err) { - try { - expect(d.validate(buffer)).to.be.null; - done(); - } catch (e) { - done(e); - return; - } - } else { - done(err); - return; - } - }); - - }); - - it('should return null for string', function () { - var d = binaryType().required(); - - expect(d.validate('foo')).to.be.null; - }); - - it('should return error for invalid format', function () { - var d = binaryType().required(); - - expect(d.validate(NaN)).to.be.instanceof(Error); - }); - -});