synamoid 1.2.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,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