synamoid 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,199 @@
1
+ # encoding: utf-8
2
+ module Dynamoid
3
+
4
+ # This module defines the finder methods that hang off the document at the
5
+ # class level, like find, find_by_id, and the method_missing style finders.
6
+ module Finders
7
+ extend ActiveSupport::Concern
8
+
9
+ RANGE_MAP = {
10
+ 'gt' => :range_greater_than,
11
+ 'lt' => :range_less_than,
12
+ 'gte' => :range_gte,
13
+ 'lte' => :range_lte,
14
+ 'begins_with' => :range_begins_with,
15
+ 'between' => :range_between,
16
+ 'eq' => :range_eq
17
+ }
18
+
19
+ module ClassMethods
20
+
21
+ # Find one or many objects, specified by one id or an array of ids.
22
+ #
23
+ # @param [Array/String] *id an array of ids or one single id
24
+ #
25
+ # @return [Dynamoid::Document] one object or an array of objects, depending on whether the input was an array or not
26
+ #
27
+ # @since 0.2.0
28
+ def find(*ids)
29
+ options = if ids.last.is_a? Hash
30
+ ids.slice!(-1)
31
+ else
32
+ {}
33
+ end
34
+ expects_array = ids.first.kind_of?(Array)
35
+
36
+ ids = Array(ids.flatten.uniq)
37
+ if ids.count == 1
38
+ result = self.find_by_id(ids.first, options)
39
+ expects_array ? Array(result) : result
40
+ else
41
+ find_all(ids)
42
+ end
43
+ end
44
+
45
+ # Return objects found by the given array of ids, either hash keys, or hash/range key combinations using BatchGet.
46
+ # Returns empty array if no results found.
47
+ #
48
+ # @param [Array<ID>] ids
49
+ # @param [Hash] options: Passed to the underlying query.
50
+ #
51
+ # @example
52
+ # find all the user with hash key
53
+ # User.find_all(['1', '2', '3'])
54
+ #
55
+ # find all the tweets using hash key and range key with consistent read
56
+ # Tweet.find_all([['1', 'red'], ['1', 'green']], :consistent_read => true)
57
+ def find_all(ids, options = {})
58
+ items = Dynamoid.adapter.read(self.table_name, ids, options)
59
+ items ? items[self.table_name].map{|i| from_database(i)} : []
60
+ end
61
+
62
+ # Find one object directly by id.
63
+ #
64
+ # @param [String] id the id of the object to find
65
+ #
66
+ # @return [Dynamoid::Document] the found object, or nil if nothing was found
67
+ #
68
+ # @since 0.2.0
69
+ def find_by_id(id, options = {})
70
+ if item = Dynamoid.adapter.read(self.table_name, id, options)
71
+ from_database(item)
72
+ else
73
+ nil
74
+ end
75
+ end
76
+
77
+ # Find one object directly by hash and range keys
78
+ #
79
+ # @param [String] hash_key of the object to find
80
+ # @param [String/Number] range_key of the object to find
81
+ #
82
+ def find_by_composite_key(hash_key, range_key, options = {})
83
+ find_by_id(hash_key, options.merge({:range_key => range_key}))
84
+ end
85
+
86
+ # Find all objects by hash and range keys.
87
+ #
88
+ # @example find all ChamberTypes whose level is greater than 1
89
+ # class ChamberType
90
+ # include Dynamoid::Document
91
+ # field :chamber_type, :string
92
+ # range :level, :integer
93
+ # table :key => :chamber_type
94
+ # end
95
+ # ChamberType.find_all_by_composite_key('DustVault', range_greater_than: 1)
96
+ #
97
+ # @param [String] hash_key of the objects to find
98
+ # @param [Hash] options the options for the range key
99
+ # @option options [Range] :range_value find the range key within this range
100
+ # @option options [Number] :range_greater_than find range keys greater than this
101
+ # @option options [Number] :range_less_than find range keys less than this
102
+ # @option options [Number] :range_gte find range keys greater than or equal to this
103
+ # @option options [Number] :range_lte find range keys less than or equal to this
104
+ #
105
+ # @return [Array] an array of all matching items
106
+ #
107
+ def find_all_by_composite_key(hash_key, options = {})
108
+ Dynamoid.adapter.query(self.table_name, options.merge({hash_value: hash_key})).collect do |item|
109
+ from_database(item)
110
+ end
111
+ end
112
+
113
+ # Find all objects by using local secondary or global secondary index
114
+ #
115
+ # @example
116
+ # class User
117
+ # include Dynamoid::Document
118
+ # field :email, :string
119
+ # field :age, :integer
120
+ # field :gender, :string
121
+ # field :rank :number
122
+ # table :key => :email
123
+ # global_secondary_index :hash_key => :age, :range_key => :rank
124
+ # end
125
+ # # NOTE: the first param and the second param are both hashes,
126
+ # # so curly braces must be used on first hash param if sending both params
127
+ # User.find_all_by_secondary_index({:age => 5}, :range => {"rank.lte" => 10})
128
+ #
129
+ # @param [Hash] eg: {:age => 5}
130
+ # @param [Hash] eg: {"rank.lte" => 10}
131
+ # @param [Hash] options - query filter, projected keys, scan_index_forward etc
132
+ # @return [Array] an array of all matching items
133
+ def find_all_by_secondary_index(hash, options = {})
134
+ range = options[:range] || {}
135
+ hash_key_field, hash_key_value = hash.first
136
+ range_key_field, range_key_value = range.first
137
+ range_op_mapped = nil
138
+
139
+ if range_key_field
140
+ range_key_field = range_key_field.to_s
141
+ range_key_op = "eq"
142
+ if range_key_field.include?(".")
143
+ range_key_field, range_key_op = range_key_field.split(".", 2)
144
+ end
145
+ range_op_mapped = RANGE_MAP.fetch(range_key_op)
146
+ end
147
+
148
+ # Find the index
149
+ index = self.find_index(hash_key_field, range_key_field)
150
+ raise Dynamoid::Errors::MissingIndex.new("attempted to find #{[hash_key_field, range_key_field]}") if index.nil?
151
+
152
+ # query
153
+ opts = {
154
+ :hash_key => hash_key_field.to_s,
155
+ :hash_value => hash_key_value,
156
+ :index_name => index.name,
157
+ }
158
+ if range_key_field
159
+ opts[:range_key] = range_key_field
160
+ opts[range_op_mapped] = range_key_value
161
+ end
162
+ dynamo_options = opts.merge(options.reject {|key, _| key == :range })
163
+ Dynamoid.adapter.query(self.table_name, dynamo_options).map do |item|
164
+ from_database(item)
165
+ end
166
+ end
167
+
168
+ # Find using exciting method_missing finders attributes. Uses criteria chains under the hood to accomplish this neatness.
169
+ #
170
+ # @example find a user by a first name
171
+ # User.find_by_first_name('Josh')
172
+ #
173
+ # @example find all users by first and last name
174
+ # User.find_all_by_first_name_and_last_name('Josh', 'Symonds')
175
+ #
176
+ # @return [Dynamoid::Document/Array] the found object, or an array of found objects if all was somewhere in the method
177
+ #
178
+ # @since 0.2.0
179
+ def method_missing(method, *args)
180
+ if method =~ /find/
181
+ finder = method.to_s.split('_by_').first
182
+ attributes = method.to_s.split('_by_').last.split('_and_')
183
+
184
+ chain = Dynamoid::Criteria::Chain.new(self)
185
+ chain.query = Hash.new.tap {|h| attributes.each_with_index {|attr, index| h[attr.to_sym] = args[index]}}
186
+
187
+ if finder =~ /all/
188
+ return chain.all
189
+ else
190
+ return chain.first
191
+ end
192
+ else
193
+ super
194
+ end
195
+ end
196
+ end
197
+ end
198
+
199
+ end
@@ -0,0 +1,92 @@
1
+ module Dynamoid
2
+ module IdentityMap
3
+ extend ActiveSupport::Concern
4
+
5
+ def self.clear
6
+ Dynamoid.included_models.each { |m| m.identity_map.clear }
7
+ end
8
+
9
+ module ClassMethods
10
+ def identity_map
11
+ @identity_map ||= {}
12
+ end
13
+
14
+ def from_database(attrs = {})
15
+ return super if identity_map_off?
16
+
17
+ key = identity_map_key(attrs)
18
+ document = identity_map[key]
19
+
20
+ if document.nil?
21
+ document = super
22
+ identity_map[key] = document
23
+ else
24
+ document.load(attrs)
25
+ end
26
+
27
+ document
28
+ end
29
+
30
+ def find_by_id(id, options = {})
31
+ return super if identity_map_off?
32
+
33
+ key = id.to_s
34
+
35
+ if range_key = options[:range_key]
36
+ key += "::#{range_key}"
37
+ end
38
+
39
+ if identity_map[key]
40
+ identity_map[key]
41
+ else
42
+ super
43
+ end
44
+ end
45
+
46
+ def identity_map_key(attrs)
47
+ key = attrs[hash_key].to_s
48
+ if range_key
49
+ key += "::#{attrs[range_key]}"
50
+ end
51
+ key
52
+ end
53
+
54
+ def identity_map_on?
55
+ Dynamoid::Config.identity_map
56
+ end
57
+
58
+ def identity_map_off?
59
+ !identity_map_on?
60
+ end
61
+ end
62
+
63
+ def identity_map
64
+ self.class.identity_map
65
+ end
66
+
67
+ def save(*args)
68
+ return super if self.class.identity_map_off?
69
+
70
+ if result = super
71
+ identity_map[identity_map_key] = self
72
+ end
73
+ result
74
+ end
75
+
76
+ def delete
77
+ return super if self.class.identity_map_off?
78
+
79
+ identity_map.delete(identity_map_key)
80
+ super
81
+ end
82
+
83
+
84
+ def identity_map_key
85
+ key = hash_key.to_s
86
+ if self.class.range_key
87
+ key += "::#{range_value}"
88
+ end
89
+ key
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,273 @@
1
+ module Dynamoid
2
+ module Indexes
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :local_secondary_indexes
7
+ class_attribute :global_secondary_indexes
8
+ self.local_secondary_indexes = {}
9
+ self.global_secondary_indexes = {}
10
+ end
11
+
12
+ module ClassMethods
13
+ # Defines a Global Secondary index on a table. Keys can be specified as
14
+ # hash-only, or hash & range.
15
+ #
16
+ # @param [Hash] options options to pass for this table
17
+ # @option options [Symbol] :name the name for the index; this still gets
18
+ # namespaced. If not specified, will use a default name.
19
+ # @option options [Symbol] :hash_key the index hash key column.
20
+ # @option options [Symbol] :range_key the index range key column (if
21
+ # applicable).
22
+ # @option options [Symbol, Array<Symbol>] :projected_attributes table
23
+ # attributes to project for this index. Can be :keys_only, :all
24
+ # or an array of included fields. If not specified, defaults to
25
+ # :keys_only.
26
+ # @option options [Integer] :read_capacity set the read capacity for the
27
+ # index; does not work on existing indexes.
28
+ # @option options [Integer] :write_capacity set the write capacity for
29
+ # the index; does not work on existing indexes.
30
+ def global_secondary_index(options={})
31
+ unless options.present?
32
+ raise Dynamoid::Errors::InvalidIndex.new('empty index definition')
33
+ end
34
+
35
+ unless options[:hash_key].present?
36
+ raise Dynamoid::Errors::InvalidIndex.new(
37
+ 'A global secondary index requires a :hash_key to be specified'
38
+ )
39
+ end
40
+
41
+ index_opts = {
42
+ :read_capacity => Dynamoid::Config.read_capacity,
43
+ :write_capacity => Dynamoid::Config.write_capacity
44
+ }.merge(options)
45
+
46
+ index_opts[:dynamoid_class] = self
47
+ index_opts[:type] = :global_secondary
48
+
49
+ index = Dynamoid::Indexes::Index.new(index_opts)
50
+ gsi_key = index_key(options[:hash_key], options[:range_key])
51
+ self.global_secondary_indexes[gsi_key] = index
52
+ self
53
+ end
54
+
55
+
56
+ # Defines a local secondary index on a table. Will use the same primary
57
+ # hash key as the table.
58
+ #
59
+ # @param [Hash] options options to pass for this index.
60
+ # @option options [Symbol] :name the name for the index; this still gets
61
+ # namespaced. If not specified, a name is automatically generated.
62
+ # @option options [Symbol] :range_key the range key column for the index.
63
+ # @option options [Symbol, Array<Symbol>] :projected_attributes table
64
+ # attributes to project for this index. Can be :keys_only, :all
65
+ # or an array of included fields. If not specified, defaults to
66
+ # :keys_only.
67
+ def local_secondary_index(options={})
68
+ unless options.present?
69
+ raise Dynamoid::Errors::InvalidIndex.new('empty index definition')
70
+ end
71
+
72
+ primary_hash_key = self.hash_key
73
+ primary_range_key = self.range_key
74
+ index_range_key = options[:range_key]
75
+
76
+ unless index_range_key.present?
77
+ raise Dynamoid::Errors::InvalidIndex.new('A local secondary index '\
78
+ 'requires a :range_key to be specified')
79
+ end
80
+
81
+ if primary_range_key.present? && index_range_key == primary_range_key
82
+ raise Dynamoid::Errors::InvalidIndex.new('A local secondary index'\
83
+ ' must use a different :range_key than the primary key')
84
+ end
85
+
86
+ index_opts = options.merge({
87
+ :dynamoid_class => self,
88
+ :type => :local_secondary,
89
+ :hash_key => primary_hash_key
90
+ })
91
+
92
+ index = Dynamoid::Indexes::Index.new(index_opts)
93
+ key = index_key(primary_hash_key, index_range_key)
94
+ self.local_secondary_indexes[key] = index
95
+ self
96
+ end
97
+
98
+
99
+ def find_index(hash, range=nil)
100
+ index = self.indexes[index_key(hash, range)]
101
+ index
102
+ end
103
+
104
+
105
+ # Returns true iff the provided hash[,range] key combo is a local
106
+ # secondary index.
107
+ #
108
+ # @param [Symbol] hash hash key name.
109
+ # @param [Symbol] range range key name.
110
+ # @return [Boolean] true iff provided keys correspond to a local
111
+ # secondary index.
112
+ def is_local_secondary_index?(hash, range=nil)
113
+ self.local_secondary_indexes[index_key(hash, range)].present?
114
+ end
115
+
116
+
117
+ # Returns true iff the provided hash[,range] key combo is a global
118
+ # secondary index.
119
+ #
120
+ # @param [Symbol] hash hash key name.
121
+ # @param [Symbol] range range key name.
122
+ # @return [Boolean] true iff provided keys correspond to a global
123
+ # secondary index.
124
+ def is_global_secondary_index?(hash, range=nil)
125
+ self.global_secondary_indexes[index_key(hash, range)].present?
126
+ end
127
+
128
+
129
+ # Generates a convenient lookup key name for a hash/range index.
130
+ # Should normally not be used directly.
131
+ #
132
+ # @param [Symbol] hash hash key name.
133
+ # @param [Symbol] range range key name.
134
+ # @return [String] returns "hash" if hash only, "hash_range" otherwise.
135
+ def index_key(hash, range=nil)
136
+ name = hash.to_s
137
+ if range.present?
138
+ name += "_#{range.to_s}"
139
+ end
140
+ name
141
+ end
142
+
143
+
144
+ # Generates a default index name.
145
+ #
146
+ # @param [Symbol] hash hash key name.
147
+ # @param [Symbol] range range key name.
148
+ # @return [String] index name of the form "table_name_index_index_key".
149
+ def index_name(hash, range=nil)
150
+ "#{self.table_name}_index_#{self.index_key(hash, range)}"
151
+ end
152
+
153
+
154
+ # Convenience method to return all indexes on the table.
155
+ #
156
+ # @return [Hash<String, Object>] the combined hash of global and local
157
+ # secondary indexes.
158
+ def indexes
159
+ self.local_secondary_indexes.merge(self.global_secondary_indexes)
160
+ end
161
+
162
+ def indexed_hash_keys
163
+ self.global_secondary_indexes.map do |name, index|
164
+ index.hash_key.to_s
165
+ end
166
+ end
167
+ end
168
+
169
+
170
+ # Represents the attributes of a DynamoDB index.
171
+ class Index
172
+ include ActiveModel::Validations
173
+
174
+ PROJECTION_TYPES = [:keys_only, :all].to_set
175
+ DEFAULT_PROJECTION_TYPE = :keys_only
176
+
177
+ attr_accessor :name, :dynamoid_class, :type, :hash_key, :range_key,
178
+ :hash_key_schema, :range_key_schema, :projected_attributes,
179
+ :read_capacity, :write_capacity
180
+
181
+
182
+ validate do
183
+ validate_index_type
184
+ validate_hash_key
185
+ validate_range_key
186
+ validate_projected_attributes
187
+ end
188
+
189
+
190
+ def initialize(attrs={})
191
+ unless attrs[:dynamoid_class].present?
192
+ raise Dynamoid::Errors::InvalidIndex.new(':dynamoid_class is required')
193
+ end
194
+
195
+ @dynamoid_class = attrs[:dynamoid_class]
196
+ @type = attrs[:type]
197
+ @hash_key = attrs[:hash_key]
198
+ @range_key = attrs[:range_key]
199
+ @name = attrs[:name] || @dynamoid_class.index_name(@hash_key, @range_key)
200
+ @projected_attributes =
201
+ attrs[:projected_attributes] || DEFAULT_PROJECTION_TYPE
202
+ @read_capacity = attrs[:read_capacity]
203
+ @write_capacity = attrs[:write_capacity]
204
+
205
+ raise Dynamoid::Errors::InvalidIndex.new(self) unless self.valid?
206
+ end
207
+
208
+
209
+ # Convenience method to determine the projection type for an index.
210
+ # Projection types are: :keys_only, :all, :include.
211
+ #
212
+ # @return [Symbol] the projection type.
213
+ def projection_type
214
+ if @projected_attributes.is_a? Array
215
+ :include
216
+ else
217
+ @projected_attributes
218
+ end
219
+ end
220
+
221
+
222
+ private
223
+
224
+ def validate_projected_attributes
225
+ unless (@projected_attributes.is_a?(Array) ||
226
+ PROJECTION_TYPES.include?(@projected_attributes))
227
+ errors.add(:projected_attributes, 'Invalid projected attributes specified.')
228
+ end
229
+ end
230
+
231
+ def validate_index_type
232
+ unless (@type.present? &&
233
+ [:local_secondary, :global_secondary].include?(@type))
234
+ errors.add(:type, 'Invalid index :type specified')
235
+ end
236
+ end
237
+
238
+ def validate_range_key
239
+ if @range_key.present?
240
+ range_field_attributes = @dynamoid_class.attributes[@range_key]
241
+ if range_field_attributes.present?
242
+ range_key_type = range_field_attributes[:type]
243
+ if Dynamoid::Fields::PERMITTED_KEY_TYPES.include?(range_key_type)
244
+ @range_key_schema = {
245
+ @range_key => @dynamoid_class.dynamo_type(range_key_type)
246
+ }
247
+ else
248
+ errors.add(:range_key, 'Index :range_key is not a valid key type')
249
+ end
250
+ else
251
+ errors.add(:range_key, "No such field #{@range_key} defined on table")
252
+ end
253
+ end
254
+ end
255
+
256
+ def validate_hash_key
257
+ hash_field_attributes = @dynamoid_class.attributes[@hash_key]
258
+ if hash_field_attributes.present?
259
+ hash_field_type = hash_field_attributes[:type]
260
+ if Dynamoid::Fields::PERMITTED_KEY_TYPES.include?(hash_field_type)
261
+ @hash_key_schema = {
262
+ @hash_key => @dynamoid_class.dynamo_type(hash_field_type)
263
+ }
264
+ else
265
+ errors.add(:hash_key, 'Index :hash_key is not a valid key type')
266
+ end
267
+ else
268
+ errors.add(:hash_key, "No such field #{@hash_key} defined on table")
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end