dyna_model 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.
@@ -0,0 +1,11 @@
1
+ class Symbol
2
+
3
+ DynaModel::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,77 @@
1
+ # TODO: optimistic locking?
2
+
3
+ module DynaModel
4
+ module Persistence
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+ def populate_id
9
+ #@_id = UUIDTools::UUID.random_create.to_s.downcase
10
+ end
11
+
12
+ private
13
+ def dynamo_db_table
14
+ self.class.dynamo_db_table(shard)
15
+ end
16
+
17
+ private
18
+ def create_storage
19
+ run_callbacks :save do
20
+ run_callbacks :create do
21
+ self.class.dynamo_db_table.write(serialize_attributes)
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+ def update_storage
28
+ # Only enumerating dirty (i.e. changed) attributes. Empty
29
+ # (nil and empty set) values are deleted, the others are replaced.
30
+ attr_updates = {}
31
+ changed.each do |attr_name|
32
+ attribute = self.class.attribute_for(attr_name)
33
+ value = serialize_attribute(attribute, @_data[attr_name])
34
+ if value.nil? or value == []
35
+ attr_updates[attr_name] = nil
36
+ else
37
+ attr_updates[attr_name] = value
38
+ end
39
+ end
40
+
41
+ run_callbacks :save do
42
+ run_callbacks :update do
43
+ self.class.dynamo_db_table.write(attr_updates, {
44
+ update_item: dynamo_db_item_key_values,
45
+ shard_name: self.shard
46
+ })
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+ def delete_storage
53
+ run_callbacks :destroy do
54
+ self.class.dynamo_db_table.delete_item(
55
+ delete_item: dynamo_db_item_key_values,
56
+ shard_name: self.shard
57
+ )
58
+ end
59
+ end
60
+
61
+ private
62
+ def deserialize_item_data data
63
+ data.inject({}) do |hash,(attr_name,value)|
64
+ if attribute = self.class.attributes[attr_name]
65
+ hash[attr_name] = value.is_a?(Set) ?
66
+ value.map{|v| attribute.deserialize(v) } :
67
+ attribute.deserialize(value)
68
+ end
69
+ hash
70
+ end
71
+ end
72
+
73
+ module ClassMethods
74
+ end
75
+
76
+ end
77
+ end
@@ -0,0 +1,207 @@
1
+ module DynaModel
2
+ module Query
3
+ extend ActiveSupport::Concern
4
+
5
+ # Failsafe
6
+ QUERY_TIMEOUT = 30 # seconds
7
+ DEFAULT_BATCH_SIZE = 100
8
+
9
+ module ClassMethods
10
+
11
+ def read_guid(guid, options={})
12
+ return nil if guid.blank?
13
+ if self.range_key
14
+ hash_value, range_value = guid.split(self.guid_delimiter)
15
+ self.read(hash_value, range_value, options)
16
+ else
17
+ self.read(guid, options)
18
+ end
19
+ end
20
+
21
+ def read(hash_value, range_value_or_options=nil, options=nil)
22
+ if self.range_key.nil?
23
+ item_attrs = self.dynamo_db_table.get_item(hash_value, range_value_or_options || {})[:item]
24
+ return nil if item_attrs.nil?
25
+ self.obj_from_attrs(Table.values_from_response_hash(item_attrs), (range_value_or_options || {}))
26
+ else
27
+ raise ArgumentError, "This table requires a range_key_value" if range_value_or_options.nil?
28
+ self.read_range(hash_value, (options || {}).merge(range: { self.range_key[:attribute_name].to_sym.eq => range_value_or_options})).first
29
+ end
30
+ end
31
+
32
+ def read_multiple(keys, options={})
33
+ results_map = {}
34
+ results = self.dynamo_db_table.batch_get_item(keys, options)
35
+ results[:responses][self.dynamo_db_table_name(options[:shard_name])].each do |result|
36
+ attrs = Response.strip_attr_types(result)
37
+ obj = self.obj_from_attrs(attrs, options)
38
+ if self.dynamo_db_table.range_keys.present? && primary_range_key = self.dynamo_db_table.range_keys.find{|rk| rk[:primary_range_key] }
39
+ (results_map[attrs[self.dynamo_db_table.hash_key[:attribute_name]]] ||= {})[attrs[primary_range_key[:attribute_name]]] = obj
40
+ else
41
+ results_map[attrs[self.dynamo_db_table.hash_key[:attribute_name]]] = obj
42
+ end
43
+ end
44
+ results_map
45
+ end
46
+
47
+ # Read results up to the limit
48
+ # read_range("1", :range => { :varname.gte => "2"}, :limit => 10)
49
+ # Loop results in given batch size until limit is hit or no more results
50
+ # read_range("1", :range => { :varname.eq => "2"}, :batch => 10, :limit => 1000)
51
+ def read_range(hash_value, options={})
52
+ raise ArgumentError, "no range_key specified for this table" if self.dynamo_db_table.range_keys.blank? && self.global_secondary_indexes.blank?
53
+ aggregated_results = []
54
+
55
+ # Useful if doing pagination where you would need the last key evaluated
56
+ return_last_evaluated_key = options.delete(:return_last_evaluated_key)
57
+ batch_size = options.delete(:batch) || DEFAULT_BATCH_SIZE
58
+ max_results_limit = options[:limit]
59
+ if options[:limit] && options[:limit] > batch_size
60
+ options.merge!(:limit => batch_size)
61
+ end
62
+
63
+ results = self.dynamo_db_table.query(hash_value, options)
64
+ response = Response.new(results)
65
+
66
+ results[:member].each do |result|
67
+ attrs = Response.strip_attr_types(result)
68
+ aggregated_results << self.obj_from_attrs(attrs, options)
69
+ end
70
+
71
+ if response.more_results?
72
+ results_returned = response.count
73
+ batch_iteration = 0
74
+ Timeout::timeout(QUERY_TIMEOUT) do
75
+ while response.more_results?
76
+ if max_results_limit && (delta_results_limit = (max_results_limit-results_returned)) < batch_size
77
+ break if delta_results_limit == 0
78
+ options.merge!(limit: delta_results_limit)
79
+ else
80
+ options.merge!(limit: batch_size)
81
+ end
82
+
83
+ results = self.dynamo_db_table.query(hash_value, options.merge(exclusive_start_key: response.last_evaluated_key))
84
+ response = Response.new(results)
85
+ results[:member].each do |result|
86
+ attrs = Response.strip_attr_types(result)
87
+ aggregated_results << self.obj_from_attrs(attrs, options)
88
+ end
89
+ results_returned += response.count
90
+ batch_iteration += 1
91
+ end
92
+ end
93
+ end
94
+
95
+ if return_last_evaluated_key
96
+ {
97
+ last_evaluated_key: response.last_evaluated_key,
98
+ members: aggregated_results
99
+ }
100
+ else
101
+ aggregated_results
102
+ end
103
+ end
104
+
105
+ def count_range(hash_value, options={})
106
+ raise ArgumentError, "no range_key specified for this table" if self.dynamo_db_table.range_keys.blank?
107
+ results = self.dynamo_db_table.query(hash_value, options.merge(select: :count))
108
+ Response.new(results).count
109
+ end
110
+
111
+ def read_first(hash_value, options={})
112
+ self.read_range(hash_value, options).first
113
+ end
114
+
115
+ #:count=>10, :scanned_count=>10, :last_evaluated_key=>{"guid"=>{:s=>"11f82550-5c5d-11e3-9b55-d311a43114ca"}}}
116
+ # :manual_batching => true|false
117
+ # return results with last_evaluated_key instead of automatically looping through (useful to throttle or )
118
+ def scan(options={})
119
+ aggregated_results = []
120
+
121
+ batch_size = options.delete(:batch) || DEFAULT_BATCH_SIZE
122
+ max_results_limit = options[:limit]
123
+ options[:limit] = batch_size
124
+
125
+ results = self.dynamo_db_table.scan(options)
126
+ response = Response.new(results)
127
+
128
+ results[:member].each do |result|
129
+ attrs = Response.strip_attr_types(result)
130
+ aggregated_results << self.obj_from_attrs(attrs, options)
131
+ end
132
+
133
+ if response.more_results? && !options[:manual_batching]
134
+ results_returned = response.count
135
+ batch_iteration = 0
136
+ Timeout::timeout(QUERY_TIMEOUT) do
137
+ while response.more_results?
138
+ if max_results_limit && (delta_results_limit = (max_results_limit-results_returned)) < batch_size
139
+ break if delta_results_limit == 0
140
+ options.merge!(limit: delta_results_limit)
141
+ else
142
+ options.merge!(limit: batch_size)
143
+ end
144
+
145
+ results = dynamo_table.scan(options.merge(exclusive_start_key: response.last_evaluated_key))
146
+ response = Response.new(results)
147
+ results[:member].each do |result|
148
+ attrs = Response.strip_attr_types(result)
149
+ aggregated_results << self.obj_from_attrs(attrs, options)
150
+ end
151
+ results_returned += response.count
152
+ batch_iteration += 1
153
+ end
154
+ end
155
+ end
156
+
157
+ if options[:manual_batching]
158
+ response_hash = {
159
+ results: aggregated_results,
160
+ last_evaluated_key: results[:last_evaluated_key]
161
+ }
162
+ response_hash.merge!(consumed_capacity: results[:consumed_capacity]) if results[:consumed_capacity]
163
+ response_hash
164
+ else
165
+ aggregated_results
166
+ end
167
+ end # scan
168
+
169
+ protected
170
+ def obj_from_attrs(attrs, options={})
171
+ obj = self.new(shard: self.shard_name(options[:shard_name]))
172
+ obj.send(:hydrate, nil, attrs)
173
+ if options[:select]
174
+ obj.instance_variable_set("@_select", options[:select])
175
+ if options[:select] != :all
176
+ #:all, :projected, :count, :specific
177
+ selected_attrs = []
178
+ # Primary hash/range key are always returned...
179
+ self.table_schema[:key_schema].each do |k|
180
+ selected_attrs << k[:attribute_name]
181
+ end
182
+ if options[:select] == :projected
183
+ index = ((self.table_schema[:global_secondary_indexes] || []) + (self.table_schema[:local_secondary_indexes] || [])).find { |i| i[:index_name] == options[:index_name].to_s }
184
+ raise "Index '#{options[:index_name]}' not found in table schema" unless index
185
+ index[:key_schema].each do |k|
186
+ selected_attrs << k[:attribute_name].to_s
187
+ end
188
+ if index[:projection] && index[:projection][:non_key_attributes]
189
+ index[:projection][:non_key_attributes].each do |a|
190
+ selected_attrs << a.to_s
191
+ end
192
+ end
193
+ elsif options[:select].is_a?(Array)
194
+ obj.instance_variable_set("@_select", :specific)
195
+ selected_attrs += options[:select].map(&:to_s)
196
+ end
197
+ selected_attrs.uniq!
198
+ obj.instance_variable_set("@_selected_attributes", selected_attrs.compact)
199
+ end
200
+ end
201
+ obj
202
+ end
203
+
204
+ end # ClassMethods
205
+
206
+ end
207
+ end
@@ -0,0 +1,36 @@
1
+ module DynaModel
2
+ class Response
3
+
4
+ def initialize(response)
5
+ raise ArgumentError, "response should be an AWS::Core::Response" unless response.is_a?(AWS::Core::Response)
6
+ @raw_response = response
7
+ end
8
+
9
+ #def values_from_response_hash(options = {})
10
+ #@raw_response.inject({}) do |h, (key, value_hash)|
11
+ #h.update(key => value_hash.to_a.last)
12
+ #end
13
+ #end
14
+
15
+ def count
16
+ @raw_response[:count]
17
+ end
18
+
19
+ def last_evaluated_key
20
+ @raw_response[:last_evaluated_key]
21
+ end
22
+
23
+ def more_results?
24
+ @raw_response.has_key?(:last_evaluated_key)
25
+ end
26
+
27
+ def self.strip_attr_types(hash)
28
+ attrs = {}
29
+ hash.each_pair do |k,v|
30
+ attrs[k] = v.values.first
31
+ end
32
+ attrs
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,251 @@
1
+ module DynaModel
2
+ module Schema
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+
7
+ KEY_TYPE = {
8
+ hash: "HASH",
9
+ range: "RANGE"
10
+ }
11
+
12
+ PROJECTION_TYPE = {
13
+ keys_only: "KEYS_ONLY",
14
+ all: "ALL",
15
+ include: "INCLUDE"
16
+ }
17
+
18
+ ATTR_TYPES = {
19
+ AWS::Record::Attributes::StringAttr => "S",
20
+ AWS::Record::Attributes::IntegerAttr => "N",
21
+ AWS::Record::Attributes::FloatAttr => "N",
22
+ AWS::Record::Attributes::BooleanAttr => "S",
23
+ AWS::Record::Attributes::DateTimeAttr => "N",
24
+ AWS::Record::Attributes::DateAttr => "N",
25
+ AWS::Record::Attributes::SerializedAttr => "B"
26
+ }
27
+
28
+ def table_schema
29
+ schema = {
30
+ table_name: dynamo_db_table_name,
31
+ provisioned_throughput: {
32
+ read_capacity_units: read_provision,
33
+ write_capacity_units: write_provision
34
+ },
35
+ key_schema: key_schema,
36
+ attribute_definitions: attribute_definitions
37
+ }
38
+ schema[:local_secondary_indexes] = local_secondary_indexes unless local_secondary_indexes.blank?
39
+ schema[:global_secondary_indexes] = global_secondary_indexes unless global_secondary_indexes.blank?
40
+ schema
41
+ end
42
+
43
+ def guid_delimiter(val=nil)
44
+ if val
45
+ raise(ArgumentError, "Invalid guid_delimiter") if val.blank?
46
+ @guid_delimiter = val.to_s
47
+ else
48
+ @guid_delimiter || DynaModel::Config.default_guid_delimiter
49
+ end
50
+ end
51
+
52
+ def read_provision(val=nil)
53
+ if val
54
+ raise(ArgumentError, "Invalid read provision") unless val.to_i >= 1
55
+ @dynamo_read_provision = val.to_i
56
+ else
57
+ @dynamo_read_provision || DynaModel::Config.read_provision
58
+ end
59
+ end
60
+
61
+ def write_provision(val=nil)
62
+ if val
63
+ raise(ArgumentError, "Invalid write provision") unless val.to_i >= 1
64
+ @dynamo_write_provision = val.to_i
65
+ else
66
+ @dynamo_write_provision || DynaModel::Config.write_provision
67
+ end
68
+ end
69
+
70
+ def hash_key(hash_key_key=nil)
71
+ if hash_key_key
72
+ hash_key_attribute = self.attributes[hash_key_key.to_s]
73
+ raise(ArgumentError, "Could not find attribute definition for hash_key #{hash_key_key}") unless hash_key_attribute
74
+ raise(ArgumentError, "Invalid attribute type for hash_key") unless [AWS::Record::Attributes::StringAttr, AWS::Record::Attributes::IntegerAttr, AWS::Record::Attributes::FloatAttr].include?(hash_key_attribute.class)
75
+
76
+ validates_presence_of hash_key_attribute.name.to_sym
77
+
78
+ @dynamo_hash_key = {
79
+ attribute_name: hash_key_attribute.name,
80
+ key_type: KEY_TYPE[:hash]
81
+ }
82
+ else
83
+ @dynamo_hash_key
84
+ end
85
+ end
86
+
87
+ def range_key(range_key_key=nil)
88
+ if range_key_key
89
+ range_key_attribute = self.attributes[range_key_key.to_s]
90
+ raise(ArgumentError, "Could not find attribute definition for range_key #{range_key_key}") unless range_key_attribute
91
+ raise(ArgumentError, "Invalid attribute type for range_key") unless [AWS::Record::Attributes::StringAttr, AWS::Record::Attributes::IntegerAttr, AWS::Record::Attributes::FloatAttr].include?(range_key_attribute.class)
92
+
93
+ validates_presence_of range_key_attribute.name.to_sym
94
+
95
+ @dynamo_range_key = {
96
+ attribute_name: range_key_attribute.name,
97
+ key_type: KEY_TYPE[:range]
98
+ }
99
+ else
100
+ @dynamo_range_key
101
+ end
102
+ end
103
+
104
+ # TODO - need to add projections?
105
+ def attribute_definitions
106
+ # Keys for hash/range/secondary
107
+ # S | N | B
108
+
109
+ keys = []
110
+ keys << hash_key[:attribute_name]
111
+ keys << range_key[:attribute_name] if range_key
112
+ local_secondary_indexes.each do |lsi|
113
+ keys << lsi[:key_schema].select{|h| h[:key_type] == "RANGE"}.first[:attribute_name]
114
+ end
115
+
116
+ global_secondary_indexes.each do |lsi|
117
+ lsi[:key_schema].each do |a|
118
+ keys << a[:attribute_name]
119
+ end
120
+ end
121
+
122
+ definitions = keys.uniq.collect do |k|
123
+ attr = self.attributes[k.to_s]
124
+ {
125
+ attribute_name: attr.name,
126
+ attribute_type: attribute_type_indicator(attr)
127
+ }
128
+ end
129
+ end
130
+
131
+ def attribute_type_indicator(attr)
132
+ if attr_type = ATTR_TYPES[attr.class]
133
+ attr_type
134
+ else
135
+ raise "unsupported attribute type #{attr.class}"
136
+ end
137
+ end
138
+
139
+ def key_schema
140
+ raise(ArgumentError, 'hash_key was not set for this table') if @dynamo_hash_key.blank?
141
+ schema = [hash_key]
142
+ schema << range_key if range_key
143
+ schema
144
+ end
145
+
146
+ def global_secondary_indexes
147
+ @global_secondary_indexes ||= []
148
+ end
149
+
150
+ # { hash_key: :hash_key_here, range_key: :optional_range_key_here }
151
+ # :name
152
+ # :projection
153
+ # :read_provision
154
+ # :write_provision
155
+ def global_secondary_index(index_name, options={})
156
+ options[:projection] ||= :keys_only
157
+ global_secondary_index_hash = {
158
+ projection: {},
159
+ provisioned_throughput: {
160
+ read_capacity_units: options[:read_provision] || read_provision,
161
+ write_capacity_units: options[:write_provision] || write_provision
162
+ }
163
+ }
164
+ if options[:projection].is_a?(Array) && options[:projection].size > 0
165
+ options[:projection].each do |non_key_attr|
166
+ attr = self.attributes[non_key_attr.to_s]
167
+ raise(ArgumentError, "Could not find attribute definition for projection on #{non_key_attr}") unless attr
168
+ (global_secondary_index_hash[:projection][:non_key_attributes] ||= []) << attr.name
169
+ end
170
+ global_secondary_index_hash[:projection][:projection_type] = PROJECTION_TYPE[:include]
171
+ else
172
+ raise(ArgumentError, 'projection must be :all, :keys_only, Array (or attrs)') unless options[:projection] == :keys_only || options[:projection] == :all
173
+ global_secondary_index_hash[:projection][:projection_type] = PROJECTION_TYPE[options[:projection]]
174
+ end
175
+
176
+ if !options.has_key?(:hash_key) || self.attributes[options[:hash_key].to_s].blank?
177
+ raise(ArgumentError, "Could not find attribute definition for global secondary index on hash_key specified")
178
+ end
179
+ hash_key_attr = self.attributes[options[:hash_key].to_s]
180
+
181
+ if options.has_key?(:range_key) && self.attributes[options[:range_key].to_s].blank?
182
+ raise(ArgumentError, "Could not find attribute definition for global secondary index on range_key specified")
183
+ end
184
+ range_key_attr = nil
185
+ range_key_attr = self.attributes[options[:range_key].to_s] if options.has_key?(:range_key)
186
+
187
+ ## Force naming of index_name for lookup later
188
+ #global_secondary_index_hash[:index_name] = (index_name.to_s || "#{hash_key_attr.name}#{"_#{range_key_attr.name}" if range_key_attr}_gsi_index".camelcase)
189
+ global_secondary_index_hash[:index_name] = index_name.to_s
190
+
191
+ global_secondary_index_hash[:key_schema] = [
192
+ {
193
+ attribute_name: hash_key_attr.name,
194
+ key_type: KEY_TYPE[:hash]
195
+ }
196
+ ]
197
+ global_secondary_index_hash[:key_schema] << {
198
+ attribute_name: range_key_attr.name,
199
+ key_type: KEY_TYPE[:range]
200
+ } if range_key_attr
201
+
202
+ return false if (@global_secondary_indexes ||= []).select {|i| i[:index_name] == global_secondary_index_hash[:index_name] }.present? # Do not add if we already have a range key set for this attr
203
+ (@global_secondary_indexes ||= []) << global_secondary_index_hash
204
+ end
205
+
206
+ def local_secondary_indexes
207
+ @local_secondary_indexes ||= []
208
+ end
209
+
210
+ def local_secondary_index(range_key_attr, options={})
211
+ options[:projection] ||= :keys_only
212
+ local_secondary_index_hash = {
213
+ projection: {}
214
+ }
215
+ if options[:projection].is_a?(Array) && options[:projection].size > 0
216
+ options[:projection].each do |non_key_attr|
217
+ attr = self.attributes[non_key_attr.to_s]
218
+ raise(ArgumentError, "Could not find attribute definition for projection on #{non_key_attr}") unless attr
219
+ (local_secondary_index_hash[:projection][:non_key_attributes] ||= []) << attr.name
220
+ end
221
+ local_secondary_index_hash[:projection][:projection_type] = PROJECTION_TYPE[:include]
222
+ else
223
+ raise(ArgumentError, 'projection must be :all, :keys_only, Array (or attrs)') unless options[:projection] == :keys_only || options[:projection] == :all
224
+ local_secondary_index_hash[:projection][:projection_type] = PROJECTION_TYPE[options[:projection]]
225
+ end
226
+
227
+ range_attr = self.attributes[range_key_attr.to_s]
228
+ raise(ArgumentError, "Could not find attribute definition for local secondary index on #{range_key_attr}") unless range_attr
229
+ local_secondary_index_hash[:index_name] = (options[:name] || options[:index_name] || "#{range_attr.name}_index".camelcase)
230
+
231
+ hash_key_attr = self.attributes[hash_key[:attribute_name].to_s]
232
+ raise(ArgumentError, "Could not find attribute definition for hash_key") unless hash_key_attr
233
+
234
+ local_secondary_index_hash[:key_schema] = [
235
+ {
236
+ attribute_name: hash_key_attr.name,
237
+ key_type: KEY_TYPE[:hash]
238
+ },
239
+ {
240
+ attribute_name: range_attr.name,
241
+ key_type: KEY_TYPE[:range]
242
+ }
243
+ ]
244
+ return false if (@local_secondary_indexes ||= []).select {|i| i[:index_name] == local_secondary_index_hash[:index_name] }.present? # Do not add if we already have a range key set for this attr
245
+ (@local_secondary_indexes ||= []) << local_secondary_index_hash
246
+ end
247
+
248
+ end # ClassMethods
249
+
250
+ end
251
+ end