dyna_model 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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