toy-dynamo 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []