toy-dynamo 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8a7c12efaf31f18e82b7f7eb008ef7e78c564551
4
+ data.tar.gz: 6a756b5cd20bee61f2e1e9b9b56ce79b4f5e6b88
5
+ SHA512:
6
+ metadata.gz: f2047d2af54e2e2697bd146f29dda06da93f86c4309306313700225425ae367daec3039e10be63a705633e614ccb235df38653a7de4be2b049139ee3dc6f2488
7
+ data.tar.gz: 7642b6a3cf88648a184b5e1aff30658fb760f479cc34950e06be65d44c278e92b9dab30842f67dbd7d097b321be7b5f4c62dd1ef5e030dc4839f87d83bccff1d
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.swp
19
+ *.swo
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in toy-dynamo.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Cary Dunn
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ ## Install
2
+ * toystore gem
3
+ * originally [https://github.com/jnunemaker/toystore](https://github.com/jnunemaker/toystore)
4
+ * Rails 4 compat: [https://github.com/cdunn/toystore](https://github.com/cdunn/toystore) (until merge)
5
+ * Official [aws-sdk](http://aws.amazon.com/sdkforruby/) gem
6
+
7
+ ## Config
8
+ In ActiveModel model:
9
+
10
+ ```
11
+ dynamo_table do
12
+ adapter :dynamo, AWS::DynamoDB::ClientV2.new, {:model => self}
13
+ hash_key :thread_guid
14
+ end
15
+ ```
16
+ * **Other options:**
17
+ * range_key :comment_id
18
+ * table_name "user_dynamo_table"
19
+ * read_provision 20
20
+ * write_provision 10
21
+ * local_secondary_index :created_at
22
+ * :projection => :keys_only **[default]**
23
+ * :projection => :all
24
+ * Can also specify an Array of attributes to project besides the primary keys ex:
25
+ * :projection => [:subject, :commenter_name]
26
+
27
+ ## Usage
28
+ * **Read Hash Key Only**
29
+ * Example:
30
+ * Model.read("xyz")
31
+ * Model.read("xyz", :consistent_read => true)
32
+ * **Read Hash + Range**
33
+ * Example:
34
+ * Model.read("xyz", :range_value => "123")
35
+ * Model.read("xyz", :range_value => "123", :consistent_read => true)
36
+ * **Read Multiple Hash Key Only**
37
+ * Example:
38
+ * Model.read_multiple(["abc", "xyz"])
39
+ * **Returns** Hash of Hash Keys => Object
40
+ * **Read Multiple Hash + Range**
41
+ * Example:
42
+ * Model.read_multiple([{:hash_value => "xyz", :range_value => "123"}, {:hash_value => "xyz", :range_value => "456"}], :consistent_read => true)
43
+ * Assumes all the same table
44
+ * Runs queries using the batch_get_item API call
45
+ * Limited to 100 items max (or 1MB result size)
46
+ * **Returns** Hash of Hash Keys => Hash of Range keys => Object
47
+ * **Read Range**
48
+ * Example:
49
+ * Model.read_range("xyz", :range => {:comment_id.eq => "123"})
50
+ * Model.read_range("xyz", :range => {:comment_id.gte => "123"})
51
+ * Model.read_range("xyz") (any range)
52
+ * If expecting lots of results, use batch and limit
53
+ * Model.read_range("xyz", :batch => 10, :limit => 100)
54
+ * Read 10 at a time up to a max of 100 items
55
+ * Model.read_range("xyz", :limit => 10)
56
+ * Read max 10 items in one request
57
+ * **Count Range**
58
+ * Example:
59
+ * Model.count_range("xyz", :range => {:comment_id.eq => "123"})
60
+ * Returns the number of total results (no attributes)
61
+
62
+ ## Compatibility
63
+ * Tested with
64
+ * Rails 4
65
+ * Ruby 2.0.0p0
66
+
67
+ ## TODO
68
+ * raise error if trying to use an attribute that wasn't 'select'ed (defaulting to selecting all attributes which loads everything with an extra read)
69
+ * while loop for situation where batch_get_item returns batched results
70
+ * error out on mismatch of table schema from dynamo_table schema (changed?)
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,58 @@
1
+ require 'adapter'
2
+
3
+ module Toy
4
+ module Dynamo
5
+ module Adapter
6
+ def read(key, options=nil)
7
+ options ||= {}
8
+ attrs = nil
9
+ if @options[:model].dynamo_table.range_keys.present?
10
+ raise ArgumentError, "Expected :range_value option" unless options[:range_value].present?
11
+ result = @options[:model].dynamo_table.query(key, options.merge(
12
+ :range => {
13
+ "#{@options[:model].dynamo_table.range_keys.find{|k| k[:primary_range_key] }[:attribute_name]}".to_sym.eq => options[:range_value]
14
+ }
15
+ ))
16
+ attrs = (result[:member].empty? ? nil : Response.strip_attr_types(result[:member].first))
17
+ else
18
+ result = @options[:model].dynamo_table.get_item(key, options)
19
+ attrs = (result[:item].empty? ? nil : Response.strip_attr_types(result[:item]))
20
+ end
21
+
22
+ attrs
23
+ end
24
+
25
+ def batch_read(keys, options=nil)
26
+ options ||= {}
27
+ @options[:model].dynamo_table.batch_get_item(keys, options)
28
+ end
29
+
30
+ def write(key, attributes, options=nil)
31
+ options ||= {}
32
+ @options[:model].dynamo_table.write(key, attributes, options)
33
+ end
34
+
35
+ def delete(key, options=nil)
36
+ options ||= {}
37
+ @options[:model].dynamo_table.delete_item(key, options)
38
+ end
39
+
40
+ def clear(options=nil)
41
+ @options[:model].dynamo_table.delete
42
+ end
43
+
44
+ private
45
+
46
+ def attributes_from_result(result)
47
+ attrs = {}
48
+ result.each_pair do |k,v|
49
+ attrs[k] = v.values.first
50
+ end
51
+ attrs
52
+ end
53
+
54
+ end
55
+ end
56
+ end
57
+
58
+ Adapter.define(:dynamo, Toy::Dynamo::Adapter)
@@ -0,0 +1,29 @@
1
+ module Toy
2
+ module Attributes
3
+
4
+ # [OVERRIDE] 'write_attribute' to account for setting hash_key and id to same value
5
+ # u.id = 1
6
+ # * set id to 1
7
+ # * set hash_key to 1
8
+ # u.hash_key = 2
9
+ # * set hash_key to 2
10
+ # * set id to 2
11
+ def write_attribute(key, value)
12
+ key = key.to_s
13
+ attribute = attribute_instance(key)
14
+
15
+ if self.class.dynamo_table.hash_key[:attribute_name] != "id" # If primary hash_key is not the standard `id`
16
+ if key == self.class.dynamo_table.hash_key[:attribute_name]
17
+ @attributes[key] = attribute_instance(key).from_store(value)
18
+ return @attributes["id"] = attribute_instance("id").from_store(value)
19
+ elsif key == "id"
20
+ @attributes["id"] = attribute_instance("id").from_store(value)
21
+ return @attributes[self.class.dynamo_table.hash_key[:attribute_name]] = attribute_instance(self.class.dynamo_table.hash_key[:attribute_name]).from_store(value)
22
+ end
23
+ end
24
+
25
+ @attributes[key] = attribute.from_store(value)
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,14 @@
1
+ module Toy
2
+ module Extensions
3
+ module Array
4
+ def to_store(value, *)
5
+ value = value.respond_to?(:lines) ? value.lines : value
6
+ Marshal.dump(value.to_a)
7
+ end
8
+
9
+ def from_store(value, *)
10
+ value.nil? ? store_default : (value.class.is_a?(Array) ? value : Marshal.load(value))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module Toy
2
+ module Extensions
3
+ module Boolean
4
+ def to_store(value, *)
5
+ boolean_value = value.is_a?(Boolean) ? value : Mapping[value]
6
+ boolean_value ? 't' : 'f'
7
+ end
8
+
9
+ def from_store(value, *)
10
+ Mapping[value]
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ module Toy
2
+ module Extensions
3
+ module Date
4
+ def to_store(value, *)
5
+ if value.nil? || value == ''
6
+ nil
7
+ else
8
+ date = value.is_a?(::Date) || value.is_a?(::Time) ? value : ::Date.parse(value.to_s)
9
+ #::Time.utc(date.year, date.month, date.day)
10
+ date.to_i
11
+ end
12
+ rescue
13
+ nil
14
+ end
15
+
16
+ def from_store(value, *)
17
+ Time.at(value.to_i).to_date if value.present?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ module Toy
2
+ module Extensions
3
+ module Hash
4
+ def to_store(value, *)
5
+ Marshal.dump(value)
6
+ end
7
+
8
+ def from_store(value, *)
9
+ value.nil? ? store_default : (value.class.is_a?(Hash) ? value : Marshal.load(value))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ module Toy
2
+ module Extensions
3
+ module Set
4
+ def store_default
5
+ [].to_set
6
+ end
7
+
8
+ def to_store(value, *)
9
+ Marshal.dump(value)
10
+ end
11
+
12
+ def from_store(value, *)
13
+ value.nil? ? store_default : Marshal.load(value)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ class Symbol
2
+
3
+ Toy::Dynamo::Table::COMPARISON_OPERATOR.keys.each do |oper|
4
+ class_eval <<-OPERATORS
5
+ def #{oper}
6
+ "\#\{self.to_s\}.#{oper}"
7
+ end
8
+ OPERATORS
9
+ end
10
+
11
+ end
@@ -0,0 +1,25 @@
1
+ module Toy
2
+ module Extensions
3
+ module Time
4
+ def to_store(value, *)
5
+ if value.nil? || value == ''
6
+ nil
7
+ else
8
+ time_class = ::Time.try(:zone).present? ? ::Time.zone : ::Time
9
+ time = value.is_a?(::Time) ? value : time_class.parse(value.to_s)
10
+ # strip milliseconds as Ruby does micro and bson does milli and rounding rounded wrong
11
+ time.to_i if time
12
+ end
13
+ end
14
+
15
+ def from_store(value, *)
16
+ value = ::Time.at(value.to_i)
17
+ if ::Time.try(:zone).present? && value.present?
18
+ value.in_time_zone(::Time.zone)
19
+ else
20
+ value
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,41 @@
1
+ module Toy
2
+ module Persistence
3
+
4
+ def persisted_attributes
5
+ attributes_with_values = {}
6
+ attributes_to_persist = []
7
+
8
+ if self.new_record?
9
+ attributes_to_persist = self.class.persisted_attributes
10
+ else
11
+ attributes_to_persist = self.class.persisted_attributes.select { |a|
12
+ # Persist changed attributes and always the range key if applicable (for lookup)
13
+ self.changed_attributes.keys.include?(a.name) || (self.class.dynamo_table.range_keys && self.class.dynamo_table.range_keys.find{|k| k[:primary_range_key]}[:attribute_name] == a.name)
14
+ }
15
+ end
16
+
17
+ attributes_to_persist.each do |attribute|
18
+ if (value = attribute.to_store(read_attribute(attribute.name)))
19
+ attributes_with_values[attribute.persisted_name] = value
20
+ end
21
+ end
22
+
23
+ attributes_with_values
24
+ end
25
+
26
+ def persist
27
+ adapter.write(persisted_id, persisted_attributes, {:update_item => !self.new_record?})
28
+ end
29
+
30
+ def delete
31
+ @_destroyed = true
32
+ options = {}
33
+ if self.class.dynamo_table.range_keys
34
+ range_key = self.class.dynamo_table.range_keys.find{|k| k[:primary_range_key]}
35
+ options[:range_value] = read_attribute(range_key[:attribute_name])
36
+ end
37
+ adapter.delete(persisted_id, options)
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,82 @@
1
+ module Toy
2
+ module Dynamo
3
+ module Querying
4
+ extend ActiveSupport::Concern
5
+
6
+ # Failsafe
7
+ MAX_BATCH_ITERATIONS = 100
8
+
9
+ module ClassMethods
10
+
11
+ # Read results up to the limit
12
+ # read_range("1", :range_value => "2", :limit => 10)
13
+ # Loop results in given batch size until limit is hit or no more results
14
+ # read_range("1", :range_value => "2", :batch => 10, :limit => 1000)
15
+ def read_range(hash_value, options={})
16
+ raise ArgumentError, "no range_key specified for this table" if dynamo_table.range_keys.blank?
17
+ aggregated_results = []
18
+
19
+ if (batch_size = options.delete(:batch))
20
+ max_results_limit = options[:limit]
21
+ if options[:limit] && options[:limit] > batch_size
22
+ options.merge!(:limit => batch_size)
23
+ end
24
+ end
25
+ results = dynamo_table.query(hash_value, options)
26
+ response = Response.new(results)
27
+
28
+ results[:member].each do |result|
29
+ attrs = Response.strip_attr_types(result)
30
+ aggregated_results << load(attrs[dynamo_table.hash_key[:attribute_name]], attrs)
31
+ end
32
+
33
+ if batch_size
34
+ results_returned = response.count
35
+ batch_iteration = 0
36
+ while response.more_results? && batch_iteration < MAX_BATCH_ITERATIONS
37
+ if max_results_limit && (delta_results_limit = (max_results_limit-results_returned)) < batch_size
38
+ break if delta_results_limit == 0
39
+ options.merge!(:limit => delta_results_limit)
40
+ else
41
+ options.merge!(:limit => batch_size)
42
+ end
43
+
44
+ results = dynamo_table.query(hash_value, options.merge(:exclusive_start_key => response.last_evaluated_key))
45
+ response = Response.new(results)
46
+ results[:member].each do |result|
47
+ attrs = Response.strip_attr_types(result)
48
+ aggregated_results << load(attrs[dynamo_table.hash_key[:attribute_name]], attrs)
49
+ end
50
+ results_returned += response.count
51
+ batch_iteration += 1
52
+ end
53
+ end
54
+
55
+ aggregated_results
56
+ end
57
+
58
+ def count_range(hash_value, options={})
59
+ raise ArgumentError, "no range_key specified for this table" if dynamo_table.range_keys.blank?
60
+ results = dynamo_table.query(hash_value, options.merge(:select => :count))
61
+ Response.new(results).count
62
+ end
63
+
64
+ def read_multiple(keys, options=nil)
65
+ results_map = {}
66
+ results = adapter.batch_read(keys, options)
67
+ results[:responses][dynamo_table.table_schema[:table_name]].each do |result|
68
+ attrs = Response.strip_attr_types(result)
69
+ if dynamo_table.range_keys.present?
70
+ (results_map[attrs[dynamo_table.hash_key[:attribute_name]]] ||= {})[attrs[dynamo_table.range_keys.find{|rk| rk[:primary_range_key] }[:attribute_name]]] = load(attrs[dynamo_table.hash_key[:attribute_name]], attrs)
71
+ else
72
+ results_map[attrs[dynamo_table.hash_key[:attribute_name]]] = load(attrs[dynamo_table.hash_key[:attribute_name]], attrs)
73
+ end
74
+ end
75
+ results_map
76
+ end
77
+
78
+ end # ClassMethods
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ module Toy
2
+ module Dynamo
3
+ class Response
4
+
5
+ def initialize(response)
6
+ raise ArgumentError, "response should be an AWS::Core::Response" unless response.is_a?(AWS::Core::Response)
7
+ @raw_response = response
8
+ end
9
+
10
+ #def values_from_response_hash(options = {})
11
+ #@raw_response.inject({}) do |h, (key, value_hash)|
12
+ #h.update(key => value_hash.to_a.last)
13
+ #end
14
+ #end
15
+
16
+ def count
17
+ @raw_response[:count]
18
+ end
19
+
20
+ def last_evaluated_key
21
+ @raw_response[:last_evaluated_key]
22
+ end
23
+
24
+ def more_results?
25
+ @raw_response.has_key?(:last_evaluated_key)
26
+ end
27
+
28
+ def self.strip_attr_types(hash)
29
+ attrs = {}
30
+ hash.each_pair do |k,v|
31
+ attrs[k] = v.values.first
32
+ end
33
+ attrs
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,212 @@
1
+ module Toy
2
+ module Dynamo
3
+ module Schema
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+
8
+ KEY_TYPE = {
9
+ :hash => "HASH",
10
+ :range => "RANGE"
11
+ }
12
+
13
+ PROJECTION_TYPE = {
14
+ :keys_only => "KEYS_ONLY",
15
+ :all => "ALL",
16
+ :include => "INCLUDE"
17
+ }
18
+
19
+ def dynamo_table(&block)
20
+ if block
21
+ @dynamo_table_config_block ||= block
22
+ else
23
+ @dynamo_table_config_block.call unless @dynamo_table_configged
24
+
25
+ @dynamo_table ||= Table.new(table_schema, self.adapter.client)
26
+ @dynamo_table_configged = true
27
+ @dynamo_table
28
+ end
29
+ end
30
+
31
+ def table_schema
32
+ schema = {
33
+ :table_name => table_name,
34
+ :provisioned_throughput => {
35
+ :read_capacity_units => read_provision,
36
+ :write_capacity_units => write_provision
37
+ },
38
+ :key_schema => key_schema,
39
+ :attribute_definitions => attribute_definitions
40
+ }
41
+ schema.merge!(:local_secondary_indexes => local_secondary_indexes) unless local_secondary_indexes.blank?
42
+ schema
43
+ end
44
+
45
+ def table_name(val=nil)
46
+ if val
47
+ raise(ArgumentError, "Invalid table name") unless val
48
+ @dynamo_table_name = val
49
+ else
50
+ @dynamo_table_name ||= "#{Rails.application.class.parent_name.to_s.underscore.dasherize}-#{self.to_s.underscore.dasherize.pluralize}-#{Rails.env}"
51
+ @dynamo_table_name
52
+ end
53
+ end
54
+
55
+ def read_provision(val=nil)
56
+ if val
57
+ raise(ArgumentError, "Invalid read provision") unless val.to_i >= 1
58
+ @dynamo_read_provision = val.to_i
59
+ else
60
+ @dynamo_read_provision || 10
61
+ end
62
+ end
63
+
64
+ def write_provision(val=nil)
65
+ if val
66
+ raise(ArgumentError, "Invalid write provision") unless val.to_i >= 1
67
+ @dynamo_write_provision = val.to_i
68
+ else
69
+ @dynamo_write_provision || 10
70
+ end
71
+ end
72
+
73
+ # TODO - need to add projections?
74
+ def attribute_definitions
75
+ # Keys for hash/range/secondary
76
+ # S | N | B
77
+ #{:attribute_name => , :attribute_type => }
78
+
79
+ keys = []
80
+ keys << hash_key[:attribute_name]
81
+ keys << range_key[:attribute_name] if range_key
82
+ local_secondary_indexes.each do |lsi|
83
+ keys << lsi[:key_schema].select{|h| h[:key_type] == "RANGE"}.first[:attribute_name]
84
+ end
85
+
86
+ definitions = keys.uniq.collect do |k|
87
+ attr = self.attributes[k.to_s]
88
+ {
89
+ :attribute_name => attr.name,
90
+ :attribute_type => attribute_type_indicator(attr.type)
91
+ }
92
+ end
93
+ end
94
+
95
+ def attribute_type_indicator(type)
96
+ if type == Array
97
+ "S"
98
+ elsif type == Boolean
99
+ "S"
100
+ elsif type == Date
101
+ "N"
102
+ elsif type == Float
103
+ "N"
104
+ elsif type == Hash
105
+ "S"
106
+ elsif type == Integer
107
+ "N"
108
+ elsif type == Object
109
+ "S"
110
+ elsif type == Set
111
+ "S"
112
+ elsif type == String
113
+ "S"
114
+ elsif type == Time
115
+ "N"
116
+ elsif type == SimpleUUID::UUID
117
+ "S"
118
+ else
119
+ raise "unsupported attribute type #{type}"
120
+ end
121
+ end
122
+
123
+ def key_schema
124
+ raise(ArgumentError, 'hash_key was not set for this table') if @dynamo_hash_key.blank?
125
+ schema = [hash_key]
126
+ schema << range_key if range_key
127
+ schema
128
+ end
129
+
130
+ def hash_key(hash_key_key=nil)
131
+ if hash_key_key
132
+ hash_key_attribute = self.attributes[hash_key_key.to_s]
133
+ raise(ArgumentError, "Could not find attribute definition for hash_key #{hash_key_key}") unless hash_key_attribute
134
+ raise(ArgumentError, "Cannot use virtual attributes for hash_key") if hash_key_attribute.virtual?
135
+ @dynamo_hash_key = {
136
+ :attribute_name => hash_key_attribute.name,
137
+ :key_type => KEY_TYPE[:hash]
138
+ }
139
+ else
140
+ @dynamo_hash_key
141
+ end
142
+ end
143
+
144
+ def range_key(range_key_key=nil)
145
+ if range_key_key
146
+ range_key_attribute = self.attributes[range_key_key.to_s]
147
+ raise(ArgumentError, "Could not find attribute definition for range_key #{range_key_key}") unless range_key_attribute
148
+ raise(ArgumentError, "Cannot use virtual attributes for range_key") if range_key_attribute.virtual?
149
+
150
+ validates_presence_of range_key_attribute.name.to_sym
151
+
152
+ @dynamo_range_key = {
153
+ :attribute_name => range_key_attribute.name,
154
+ :key_type => KEY_TYPE[:range]
155
+ }
156
+ else
157
+ @dynamo_range_key
158
+ end
159
+ end
160
+
161
+ def local_secondary_indexes
162
+ @local_secondary_indexes ||= []
163
+ end
164
+
165
+ # @param [Symbol] index_attr the attribute to index secondary
166
+ # @param [Hash] options
167
+ # @option options [Symbol, Array<String>] :projection
168
+ # * `:all`
169
+ # * `:keys_only`
170
+ # * [:attributes to project]
171
+ def local_secondary_index(range_key_attr, options={})
172
+ options[:projection] ||= :keys_only
173
+ local_secondary_index_hash = {
174
+ :projection => {}
175
+ }
176
+ if options[:projection].is_a?(Array) && options[:projection].size > 0
177
+ options[:projection].each do |non_key_attr|
178
+ attr = self.attributes[non_key_attr.to_s]
179
+ raise(ArgumentError, "Could not find attribute definition for projection on #{non_key_attr}") unless attr
180
+ (local_secondary_index_hash[:projection][:non_key_attributes] ||= []) << attr.name
181
+ end
182
+ local_secondary_index_hash[:projection][:projection_type] = PROJECTION_TYPE[:include]
183
+ else
184
+ raise(ArgumentError, 'projection must be :all, :keys_only, Array (or attrs)') unless options[:projection] == :keys_only || options[:projection] == :all
185
+ local_secondary_index_hash[:projection][:projection_type] = PROJECTION_TYPE[options[:projection]]
186
+ end
187
+
188
+ range_attr = self.attributes[range_key_attr.to_s]
189
+ raise(ArgumentError, "Could not find attribute definition for local secondary index on #{range_key_attr}") unless range_attr
190
+ local_secondary_index_hash[:index_name] = (options[:name] || "#{range_attr.name}_index".camelcase)
191
+
192
+ hash_key_attr = self.attributes[hash_key[:attribute_name].to_s]
193
+ raise(ArgumentError, "Could not find attribute definition for hash_key") unless hash_key_attr
194
+
195
+ local_secondary_index_hash[:key_schema] = [
196
+ {
197
+ :attribute_name => hash_key_attr.name,
198
+ :key_type => KEY_TYPE[:hash]
199
+ },
200
+ {
201
+ :attribute_name => range_attr.name,
202
+ :key_type => KEY_TYPE[:range]
203
+ }
204
+ ]
205
+ (@local_secondary_indexes ||= []) << local_secondary_index_hash
206
+ end
207
+
208
+ end # ClassMethods
209
+
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,359 @@
1
+ module Toy
2
+ module Dynamo
3
+ class Table
4
+
5
+ attr_reader :table_schema, :client, :schema_loaded, :hash_key, :range_keys
6
+
7
+ RETURNED_CONSUMED_CAPACITY = {
8
+ :none => "NONE",
9
+ :total => "TOTAL"
10
+ }
11
+
12
+ TYPE_INDICATOR = {
13
+ :b => "B",
14
+ :n => "N",
15
+ :s => "S",
16
+ :ss => "SS",
17
+ :ns => "NS"
18
+ }
19
+
20
+ QUERY_SELECT = {
21
+ :all => "ALL_ATTRIBUTES",
22
+ :projected => "ALL_PROJECTED_ATTRIBUTES",
23
+ :count => "COUNT",
24
+ :specific => "SPECIFIC_ATTRIBUTES"
25
+ }
26
+
27
+ COMPARISON_OPERATOR = {
28
+ :eq => "EQ",
29
+ :le => "LE",
30
+ :lt => "LT",
31
+ :ge => "GE",
32
+ :gt => "GT",
33
+ :begins_with => "BEGINS_WITH",
34
+ :between => "BETWEEN"
35
+ }
36
+
37
+ def initialize(table_schema, client)
38
+ @table_schema = table_schema
39
+ @client = client
40
+ begin
41
+ self.load_schema
42
+ rescue AWS::DynamoDB::Errors::ResourceNotFoundException => e
43
+ puts "No table found! Creating..."
44
+ self.create
45
+ self.load_schema
46
+ end
47
+ end
48
+
49
+ def load_schema
50
+ @schema_loaded = @client.describe_table(:table_name => @table_schema[:table_name])
51
+
52
+ @schema_loaded[:table][:key_schema].each do |key|
53
+ key_schema_attr = {
54
+ :attribute_name => key[:attribute_name],
55
+ :attribute_type => @table_schema[:attribute_definitions].find{|h| h[:attribute_name] == key[:attribute_name]}[:attribute_type]
56
+ }
57
+
58
+ if key[:key_type] == "HASH"
59
+ @hash_key = key_schema_attr
60
+ else
61
+ (@range_keys ||= []) << key_schema_attr.merge(:primary_range_key => true)
62
+ end
63
+ end
64
+
65
+ if @schema_loaded[:table][:local_secondary_indexes]
66
+ @schema_loaded[:table][:local_secondary_indexes].each do |key|
67
+ lsi_range_key = key[:key_schema].find{|h| h[:key_type] == "RANGE" }
68
+ (@range_keys ||= []) << {
69
+ :attribute_name => lsi_range_key[:attribute_name],
70
+ :attribute_type => @table_schema[:attribute_definitions].find{|h| h[:attribute_name] == lsi_range_key[:attribute_name]}[:attribute_type],
71
+ :index_name => key[:index_name]
72
+ }
73
+ end
74
+ end
75
+
76
+ @schema_loaded
77
+ end
78
+
79
+ def hash_key_item_param(value)
80
+ hash_key = @table_schema[:key_schema].find{|h| h[:key_type] == "HASH"}[:attribute_name]
81
+ hash_key_type = @table_schema[:attribute_definitions].find{|h| h[:attribute_name] == hash_key}[:attribute_type]
82
+ { hash_key => { hash_key_type => value } }
83
+ end
84
+
85
+ def hash_key_condition_param(value)
86
+ hash_key = @table_schema[:key_schema].find{|h| h[:key_type] == "HASH"}[:attribute_name]
87
+ hash_key_type = @table_schema[:attribute_definitions].find{|h| h[:attribute_name] == hash_key}[:attribute_type]
88
+ {
89
+ hash_key => {
90
+ :attribute_value_list => [hash_key_type => value],
91
+ :comparison_operator => COMPARISON_OPERATOR[:eq]
92
+ }
93
+ }
94
+ end
95
+
96
+ def attr_with_type(attr_name, value)
97
+ { attr_name => { TYPE_INDICATOR[type_from_value(value)] => value.to_s } }
98
+ end
99
+
100
+ def get_item(hash_key, options={})
101
+ options[:consistent_read] = false unless options[:consistent_read]
102
+ options[:return_consumed_capacity] ||= :none # "NONE" # || "TOTAL"
103
+ options[:select] ||= []
104
+
105
+ get_item_request = {
106
+ :table_name => options[:table_name] || @table_schema[:table_name],
107
+ :key => hash_key_item_param(hash_key),
108
+ :consistent_read => options[:consistent_read],
109
+ :return_consumed_capacity => RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]]
110
+ }
111
+ get_item_request.merge!( :attributes_to_get => [options[:select]].flatten ) unless options[:select].blank?
112
+ @client.get_item(get_item_request)
113
+ end
114
+
115
+ # == options
116
+ # * consistent_read
117
+ # * return_consumed_capacity
118
+ # * order
119
+ # * select
120
+ # * range
121
+ def query(hash_key_value, options={})
122
+ options[:consistent_read] = false unless options[:consistent_read]
123
+ options[:return_consumed_capacity] ||= :none # "NONE" # || "TOTAL"
124
+ options[:order] ||= :desc
125
+ #options[:index_name] ||= :none
126
+ #AWS::DynamoDB::Errors::ValidationException: ALL_PROJECTED_ATTRIBUTES can be used only when Querying using an IndexName
127
+ #options[:limit] ||= 10
128
+ #options[:exclusive_start_key]
129
+
130
+ key_conditions = {}
131
+ key_conditions.merge!(hash_key_condition_param(hash_key_value))
132
+
133
+ query_request = {
134
+ :table_name => options[:table_name] || @table_schema[:table_name],
135
+ :key_conditions => key_conditions,
136
+ :consistent_read => options[:consistent_read],
137
+ :return_consumed_capacity => RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]],
138
+ :scan_index_forward => (options[:order] == :asc)
139
+ }
140
+
141
+ if options[:range]
142
+ raise ArgumentError, "Expected a 1 element Hash for :range (ex {:age.gt => 13})" unless options[:range].is_a?(Hash) && options[:range].keys.size == 1
143
+ range_key_name, comparison_operator = options[:range].keys.first.split(".")
144
+ raise ArgumentError, "Comparison operator must be one of (#{COMPARISON_OPERATOR.keys.join(", ")})" unless COMPARISON_OPERATOR.keys.include?(comparison_operator.to_sym)
145
+ range_key = @range_keys.find{|k| k[:attribute_name] == range_key_name}
146
+ raise ArgumentError, ":range key must be a valid Range attribute" unless range_key
147
+ raise ArgumentError, ":range key must be a Range if using the operator BETWEEN" if comparison_operator == "between" && !options[:range].values.first.is_a?(Range)
148
+
149
+ if range_key.has_key?(:index_name) # Local Secondary Index
150
+ #options[:select] = :projected unless options[:select].present?
151
+ query_request.merge!(:index_name => range_key[:index_name])
152
+ end
153
+
154
+ range_value = options[:range].values.first
155
+ range_attribute_list = []
156
+ if comparison_operator == "between"
157
+ range_attribute_list << { range_key[:attribute_type] => range_value.min }
158
+ range_attribute_list << { range_key[:attribute_type] => range_value.max }
159
+ else
160
+ # TODO - support Binary?
161
+ range_attribute_list = [{ range_key[:attribute_type] => range_value.to_s }]
162
+ end
163
+
164
+ key_conditions.merge!({
165
+ range_key[:attribute_name] => {
166
+ :attribute_value_list => range_attribute_list,
167
+ :comparison_operator => COMPARISON_OPERATOR[comparison_operator.to_sym]
168
+ }
169
+ })
170
+ end
171
+
172
+ # Default if not already set
173
+ options[:select] ||= :all # :all, :projected, :count, []
174
+ if options[:select].is_a?(Array)
175
+ attrs_to_select = [options[:select].map(&:to_s)].flatten
176
+ attrs_to_select << @hash_key[:attribute_name]
177
+ attrs_to_select << @range_keys.find{|k| k[:primary_range_key] }[:attribute_name] if @range_keys
178
+ query_request.merge!({
179
+ :select => QUERY_SELECT[:specific],
180
+ :attributes_to_get => attrs_to_select.uniq
181
+ })
182
+ else
183
+ query_request.merge!({ :select => QUERY_SELECT[options[:select]] })
184
+ end
185
+
186
+ query_request.merge!({ :limit => options[:limit].to_i }) if options.has_key?(:limit)
187
+ query_request.merge!({ :exclusive_start_key => options[:exclusive_start_key] }) if options[:exclusive_start_key]
188
+
189
+ @client.query(query_request)
190
+ end
191
+
192
+ def batch_get_item(keys, options={})
193
+ options[:return_consumed_capacity] ||= :none
194
+ options[:select] ||= []
195
+ options[:consistent_read] = false unless options[:consistent_read]
196
+
197
+ raise ArgumentError, "must include between 1 - 100 keys" if keys.size == 0 || keys.size > 100
198
+ keys_request = []
199
+ keys.each do |k|
200
+ key_request = {}
201
+ if @range_keys.present?
202
+ hash_value = k[:hash_value]
203
+ else
204
+ raise ArgumentError, "expected keys to be in the form of ['hash key here'] for table with no range keys" if hash_value.is_a?(Hash)
205
+ hash_value = k
206
+ end
207
+ raise ArgumentError, "every key must include a :hash_value" if hash_value.blank?
208
+ key_request[@hash_key[:attribute_name]] = { @hash_key[:attribute_type] => hash_value.to_s }
209
+ if @range_keys.present?
210
+ range_value = k[:range_value]
211
+ raise ArgumentError, "every key must include a :range_value" if range_value.blank?
212
+ range_key = @range_keys.find{|k| k[:primary_range_key] }
213
+ key_request[range_key[:attribute_name]] = { range_key[:attribute_type] => range_value.to_s }
214
+ end
215
+ keys_request << key_request
216
+ end
217
+
218
+ request_items_request = {}
219
+ request_items_request.merge!( :keys => keys_request )
220
+ request_items_request.merge!( :attributes_to_get => [options[:select]].flatten ) unless options[:select].blank?
221
+ request_items_request.merge!( :consistent_read => options[:consistent_read] ) if options[:consistent_read]
222
+ batch_get_item_request = {
223
+ :request_items => { (options[:table_name] || @table_schema[:table_name]) => request_items_request },
224
+ :return_consumed_capacity => RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]]
225
+ }
226
+ @client.batch_get_item(batch_get_item_request)
227
+ end
228
+
229
+ def write(hash_key_value, attributes, options={})
230
+ options[:return_consumed_capacity] ||= :none
231
+ options[:update_item] = false unless options[:update_item]
232
+
233
+ if options[:update_item]
234
+ # UpdateItem
235
+ key_request = {
236
+ @hash_key[:attribute_name] => {
237
+ @hash_key[:attribute_type] => hash_key_value.to_s
238
+ }
239
+ }
240
+ if @range_keys
241
+ range_key = @range_keys.find{|k| k[:primary_range_key]}
242
+ range_key_value = attributes[range_key[:attribute_name]]
243
+ raise ArgumentError, "range_key was not provided to the write command" if range_key_value.blank?
244
+ key_request.merge!({
245
+ range_key[:attribute_name] => {
246
+ range_key[:attribute_type] => range_key_value.to_s
247
+ }
248
+ })
249
+ end
250
+ attrs_to_update = {}
251
+ attributes.each_pair do |k,v|
252
+ next if @range_keys && k == range_key[:attribute_name]
253
+ attrs_to_update.merge!({
254
+ k => {
255
+ :value => attr_with_type(k,v).values.last,
256
+ :action => "PUT"
257
+ }
258
+ })
259
+ end
260
+ update_item_request = {
261
+ :table_name => options[:table_name] || @table_schema[:table_name],
262
+ :key => key_request,
263
+ :attribute_updates => attrs_to_update,
264
+ :return_consumed_capacity => RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]]
265
+ }
266
+ @client.update_item(update_item_request)
267
+ else
268
+ # PutItem
269
+ items = {}
270
+ attributes.each_pair do |k,v|
271
+ items.merge!(attr_with_type(k,v))
272
+ end
273
+ items.merge!(hash_key_item_param(hash_key_value))
274
+ put_item_request = {
275
+ :table_name => options[:table_name] || @table_schema[:table_name],
276
+ :item => items,
277
+ :return_consumed_capacity => RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]]
278
+ }
279
+ @client.put_item(put_item_request)
280
+ end
281
+ end
282
+
283
+ def delete_item(hash_key_value, options={})
284
+ key_request = {
285
+ @hash_key[:attribute_name] => {
286
+ @hash_key[:attribute_type] => hash_key_value.to_s
287
+ }
288
+ }
289
+ if @range_keys
290
+ range_key = @range_keys.find{|k| k[:primary_range_key]}
291
+ raise ArgumentError, "range_key was not provided to the delete_item command" if options[:range_value].blank?
292
+ key_request.merge!({
293
+ range_key[:attribute_name] => {
294
+ range_key[:attribute_type] => options[:range_value].to_s
295
+ }
296
+ })
297
+ end
298
+ delete_item_request = {
299
+ :table_name => options[:table_name] || @table_schema[:table_name],
300
+ :key => key_request
301
+ }
302
+ @client.delete_item(delete_item_request)
303
+ end
304
+
305
+ def type_from_value(value)
306
+ case
307
+ when value.kind_of?(AWS::DynamoDB::Binary) then :b
308
+ when value.respond_to?(:to_str) then :s
309
+ when value.kind_of?(Numeric) then :n
310
+ when value.respond_to?(:each)
311
+ indicator = nil
312
+ value.each do |v|
313
+ member_indicator = type_indicator(v)
314
+ raise ArgumentError, "nested collections" if member_indicator.to_s.size > 1
315
+ raise ArgumentError, "mixed types" if indicator and member_indicator != indicator
316
+ indicator = member_indicator
317
+ end
318
+ indicator ||= :s
319
+ :"#{indicator}s"
320
+ else
321
+ raise ArgumentError, "unsupported attribute type #{value.class}"
322
+ end
323
+ end
324
+
325
+ def create(options={})
326
+ if @client.list_tables[:table_names].include?(options[:table_name] || @table_schema[:table_name])
327
+ raise "Table #{options[:table_name] || @table_schema[:table_name]} already exists!"
328
+ end
329
+
330
+ @client.create_table(@table_schema.merge({
331
+ :table_name => options[:table_name] || @table_schema[:table_name]
332
+ }))
333
+
334
+ while (table_metadata = self.describe)[:table][:table_status] == "CREATING"
335
+ sleep 1
336
+ end
337
+ table_metadata
338
+ end
339
+
340
+ def describe
341
+ @client.describe_table(:table_name => @table_schema[:table_name])
342
+ end
343
+
344
+ def delete(options={})
345
+ return false unless @client.list_tables[:table_names].include?(options[:table_name] || @table_schema[:table_name])
346
+ @client.delete_table(:table_name => options[:table_name] || @table_schema[:table_name])
347
+ begin
348
+ while (table_metadata = self.describe) && table_metadata[:table][:table_status] == "DELETING"
349
+ sleep 1
350
+ end
351
+ rescue AWS::DynamoDB::Errors::ResourceNotFoundException => e
352
+ puts "Table deleted!"
353
+ end
354
+ true
355
+ end
356
+
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,5 @@
1
+ module Toy
2
+ module Dynamo
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
data/lib/toy/dynamo.rb ADDED
@@ -0,0 +1,26 @@
1
+ require "toy/dynamo/version"
2
+ require "toy/dynamo/adapter"
3
+ require "toy/dynamo/schema"
4
+ require "toy/dynamo/table"
5
+ require "toy/dynamo/querying"
6
+ require "toy/dynamo/response"
7
+ require "toy/dynamo/persistence"
8
+ # Override 'write_attribute' for hash_key == id
9
+ require "toy/dynamo/attributes"
10
+ require "toy/dynamo/extensions/array"
11
+ require "toy/dynamo/extensions/boolean"
12
+ require "toy/dynamo/extensions/date"
13
+ require "toy/dynamo/extensions/hash"
14
+ require "toy/dynamo/extensions/set"
15
+ require "toy/dynamo/extensions/time"
16
+ require "toy/dynamo/extensions/symbol"
17
+
18
+ module Toy
19
+ module Dynamo
20
+ extend ActiveSupport::Concern
21
+ include Toy::Store
22
+
23
+ include Schema
24
+ include Querying
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'toy/dynamo/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "toy-dynamo"
8
+ spec.version = Toy::Dynamo::VERSION
9
+ spec.authors = ["Cary Dunn"]
10
+ spec.email = ["cary.dunn@gmail.com"]
11
+ spec.description = %q{DynamoDB ORM - extension to toystore}
12
+ spec.summary = %q{DynamoDB ORM - extension to toystore}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake"
23
+
24
+ spec.add_dependency 'adapter', '~> 0.7.0'
25
+ spec.add_dependency 'aws-sdk', '~> 1.9'
26
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: toy-dynamo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Cary Dunn
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-05-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: adapter
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ~>
46
+ - !ruby/object:Gem::Version
47
+ version: 0.7.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: 0.7.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: aws-sdk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: '1.9'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ~>
67
+ - !ruby/object:Gem::Version
68
+ version: '1.9'
69
+ description: DynamoDB ORM - extension to toystore
70
+ email:
71
+ - cary.dunn@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - .gitignore
77
+ - Gemfile
78
+ - LICENSE.txt
79
+ - README.md
80
+ - Rakefile
81
+ - lib/toy/dynamo.rb
82
+ - lib/toy/dynamo/adapter.rb
83
+ - lib/toy/dynamo/attributes.rb
84
+ - lib/toy/dynamo/extensions/array.rb
85
+ - lib/toy/dynamo/extensions/boolean.rb
86
+ - lib/toy/dynamo/extensions/date.rb
87
+ - lib/toy/dynamo/extensions/hash.rb
88
+ - lib/toy/dynamo/extensions/set.rb
89
+ - lib/toy/dynamo/extensions/symbol.rb
90
+ - lib/toy/dynamo/extensions/time.rb
91
+ - lib/toy/dynamo/persistence.rb
92
+ - lib/toy/dynamo/querying.rb
93
+ - lib/toy/dynamo/response.rb
94
+ - lib/toy/dynamo/schema.rb
95
+ - lib/toy/dynamo/table.rb
96
+ - lib/toy/dynamo/version.rb
97
+ - toy-dynamo.gemspec
98
+ homepage: ''
99
+ licenses:
100
+ - MIT
101
+ metadata: {}
102
+ post_install_message:
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - '>='
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - '>='
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubyforge_project:
118
+ rubygems_version: 2.0.0
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: DynamoDB ORM - extension to toystore
122
+ test_files: []