dynamoid 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -99,6 +99,17 @@ module Dynamoid #:nodoc:
99
99
  source.send(source_attribute) || Set.new
100
100
  end
101
101
 
102
+ # Create a new instance of the target class without trying to add it to the association. This creates a base, that caller can update before setting or adding it.
103
+ #
104
+ # @param [Hash] attribute hash for the new object
105
+ #
106
+ # @return [Dynamoid::Document] the newly-created object
107
+ #
108
+ # @since 1.1.1
109
+ def build(attributes = {})
110
+ target_class.build(attributes)
111
+ end
112
+
102
113
  end
103
114
  end
104
115
 
@@ -145,8 +145,10 @@ module Dynamoid #:nodoc:
145
145
  #
146
146
  # @since 0.2.0
147
147
  def where(args)
148
- args.each {|k, v| query[k] = v}
149
- self
148
+ filtered = clone
149
+ filtered.query = query.clone
150
+ args.each {|k, v| filtered.query[k] = v}
151
+ filtered
150
152
  end
151
153
 
152
154
  # Is this array equal to the association's records?
@@ -25,7 +25,7 @@ module Dynamoid #:nodoc:
25
25
  end
26
26
 
27
27
  def create(attributes = {})
28
- setter(target_class.create!(attributes))
28
+ setter(target_class.create(attributes))
29
29
  end
30
30
 
31
31
 
@@ -25,6 +25,7 @@ module Dynamoid
25
25
  include ActiveModel::Serializers::JSON
26
26
  include ActiveModel::Serializers::Xml
27
27
  include Dynamoid::Fields
28
+ include Dynamoid::Indexes
28
29
  include Dynamoid::Persistence
29
30
  include Dynamoid::Finders
30
31
  include Dynamoid::Associations
@@ -23,6 +23,9 @@ module Dynamoid
23
23
  option :use_ssl, :default => true
24
24
  option :port, :default => '443'
25
25
  option :identity_map, :default => false
26
+ option :timestamps, :default => true
27
+ option :sync_retry_max_times, :default => 60 # a bit over 2 minutes
28
+ option :sync_retry_wait_seconds, :default => 2
26
29
 
27
30
  # The default logger for Dynamoid: either the Rails logger or just stdout.
28
31
  #
@@ -50,22 +50,22 @@ module Dynamoid #:nodoc:
50
50
  #
51
51
  def destroy_all
52
52
  ids = []
53
-
53
+
54
54
  if key_present?
55
55
  ranges = []
56
56
  Dynamoid.adapter.query(source.table_name, range_query).collect do |hash|
57
57
  ids << hash[source.hash_key.to_sym]
58
58
  ranges << hash[source.range_key.to_sym]
59
59
  end
60
-
60
+
61
61
  Dynamoid.adapter.delete(source.table_name, ids,{:range_key => ranges})
62
62
  else
63
63
  Dynamoid.adapter.scan(source.table_name, query, scan_opts).collect do |hash|
64
64
  ids << hash[source.hash_key.to_sym]
65
65
  end
66
-
66
+
67
67
  Dynamoid.adapter.delete(source.table_name, ids)
68
- end
68
+ end
69
69
  end
70
70
 
71
71
  def eval_limit(limit)
@@ -152,13 +152,15 @@ module Dynamoid #:nodoc:
152
152
 
153
153
  case key.to_s.split('.').last
154
154
  when 'gt'
155
- { :range_greater_than => val.to_f }
155
+ { :range_greater_than => val }
156
156
  when 'lt'
157
- { :range_less_than => val.to_f }
157
+ { :range_less_than => val }
158
158
  when 'gte'
159
- { :range_gte => val.to_f }
159
+ { :range_gte => val }
160
160
  when 'lte'
161
- { :range_lte => val.to_f }
161
+ { :range_lte => val }
162
+ when 'between'
163
+ { :range_between => val }
162
164
  when 'begins_with'
163
165
  { :range_begins_with => val }
164
166
  end
@@ -166,7 +168,7 @@ module Dynamoid #:nodoc:
166
168
 
167
169
  def range_query
168
170
  opts = { :hash_value => query[source.hash_key] }
169
- if key = query.keys.find { |k| k.to_s.include?('.') }
171
+ query.keys.select { |k| k.to_s.include?('.') }.each do |key|
170
172
  opts.merge!(range_hash(key))
171
173
  end
172
174
  opts.merge(query_opts).merge(consistent_opts)
@@ -197,7 +199,7 @@ module Dynamoid #:nodoc:
197
199
  opts[:scan_index_forward] = @scan_index_forward
198
200
  opts
199
201
  end
200
-
202
+
201
203
  def scan_opts
202
204
  opts = {}
203
205
  opts[:limit] = @eval_limit if @eval_limit
@@ -72,7 +72,7 @@ module Dynamoid #:nodoc:
72
72
  #
73
73
  # @since 0.2.0
74
74
  def create(attrs = {})
75
- attrs[:type] ? attrs[:type].constantize.new(attrs).tap(&:save) : new(attrs).tap(&:save)
75
+ build(attrs).tap(&:save)
76
76
  end
77
77
 
78
78
  # Initialize a new object and immediately save it to the database. Raise an exception if persistence failed.
@@ -83,7 +83,7 @@ module Dynamoid #:nodoc:
83
83
  #
84
84
  # @since 0.2.0
85
85
  def create!(attrs = {})
86
- attrs[:type] ? attrs[:type].constantize.new(attrs).tap(&:save!) : new(attrs).tap(&:save!)
86
+ build(attrs).tap(&:save!)
87
87
  end
88
88
 
89
89
  # Initialize a new object.
@@ -130,7 +130,9 @@ module Dynamoid #:nodoc:
130
130
  end
131
131
 
132
132
  def load(attrs)
133
- self.class.undump(attrs).each {|key, value| send "#{key}=", value }
133
+ self.class.undump(attrs).each do |key, value|
134
+ send("#{key}=", value) if self.respond_to?("#{key}=")
135
+ end
134
136
  end
135
137
 
136
138
  # An object is equal to another object if their ids are equal.
@@ -1,14 +1,28 @@
1
1
  # encoding: utf-8
2
2
  module Dynamoid
3
-
3
+
4
4
  # All the errors specific to Dynamoid. The goal is to mimic ActiveRecord.
5
5
  module Errors
6
-
6
+
7
7
  # Generic Dynamoid error
8
8
  class Error < StandardError; end
9
-
9
+
10
10
  class MissingRangeKey < Error; end
11
11
 
12
+ class MissingIndex < Error; end
13
+
14
+ # InvalidIndex is raised when an invalid index is specified, for example if
15
+ # specified key attribute(s) or projected attributes do not exist.
16
+ class InvalidIndex < Error
17
+ def initialize(item)
18
+ if (item.is_a? String)
19
+ super(item)
20
+ else
21
+ super("Validation failed: #{item.errors.full_messages.join(", ")}")
22
+ end
23
+ end
24
+ end
25
+
12
26
  # This class is intended to be private to Dynamoid.
13
27
  class ConditionalCheckFailedException < Error
14
28
  attr_reader :inner_exception
@@ -1,11 +1,17 @@
1
1
  # encoding: utf-8
2
2
  module Dynamoid #:nodoc:
3
-
4
3
  # All fields on a Dynamoid::Document must be explicitly defined -- if you have fields in the database that are not
5
4
  # specified with field, then they will be ignored.
6
5
  module Fields
7
6
  extend ActiveSupport::Concern
8
7
 
8
+ PERMITTED_KEY_TYPES = [
9
+ :number,
10
+ :integer,
11
+ :string,
12
+ :datetime
13
+ ]
14
+
9
15
  # Initialize the attributes we know the class has, in addition to our magic attributes: id, created_at, and updated_at.
10
16
  included do
11
17
  class_attribute :attributes
@@ -45,7 +51,15 @@ module Dynamoid #:nodoc:
45
51
  self.attributes = attributes.merge(name => {:type => type}.merge(options))
46
52
 
47
53
  define_method(named) { read_attribute(named) }
48
- define_method("#{named}?") { !read_attribute(named).nil? }
54
+ define_method("#{named}?") do
55
+ value = read_attribute(named)
56
+ case value
57
+ when true then true
58
+ when false, nil then false
59
+ else
60
+ !value.nil?
61
+ end
62
+ end
49
63
  define_method("#{named}=") {|value| write_attribute(named, value) }
50
64
  end
51
65
 
@@ -131,14 +145,14 @@ module Dynamoid #:nodoc:
131
145
  #
132
146
  # @since 0.2.0
133
147
  def set_created_at
134
- self.created_at = DateTime.now
148
+ self.created_at = DateTime.now if Dynamoid::Config.timestamps
135
149
  end
136
150
 
137
151
  # Automatically called during the save callback to set the updated_at time.
138
152
  #
139
153
  # @since 0.2.0
140
154
  def set_updated_at
141
- self.updated_at = DateTime.now
155
+ self.updated_at = DateTime.now if Dynamoid::Config.timestamps
142
156
  end
143
157
 
144
158
  def set_type
@@ -147,4 +161,4 @@ module Dynamoid #:nodoc:
147
161
 
148
162
  end
149
163
 
150
- end
164
+ end
@@ -6,6 +6,16 @@ module Dynamoid
6
6
  module Finders
7
7
  extend ActiveSupport::Concern
8
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
+
9
19
  module ClassMethods
10
20
 
11
21
  # Find one or many objects, specified by one id or an array of ids.
@@ -100,6 +110,61 @@ module Dynamoid
100
110
  end
101
111
  end
102
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 - @TODO support more options in future such as
132
+ # query filter, projected keys etc
133
+ # @return [Array] an array of all matching items
134
+ def find_all_by_secondary_index(hash, options = {})
135
+ range = options[:range] || {}
136
+ hash_key_field, hash_key_value = hash.first
137
+ range_key_field, range_key_value = range.first
138
+ range_op_mapped = nil
139
+
140
+ if range_key_field
141
+ range_key_field = range_key_field.to_s
142
+ range_key_op = "eq"
143
+ if range_key_field.include?(".")
144
+ range_key_field, range_key_op = range_key_field.split(".", 2)
145
+ end
146
+ range_op_mapped = RANGE_MAP.fetch(range_key_op)
147
+ end
148
+
149
+ # Find the index
150
+ index = self.find_index(hash_key_field, range_key_field)
151
+ raise Dynamoid::Errors::MissingIndex if index.nil?
152
+
153
+ # query
154
+ opts = {
155
+ :hash_key => hash_key_field.to_s,
156
+ :hash_value => hash_key_value,
157
+ :index_name => index.name,
158
+ }
159
+ if range_key_field
160
+ opts[:range_key] = range_key_field
161
+ opts[range_op_mapped] = range_key_value
162
+ end
163
+ Dynamoid.adapter.query(self.table_name, opts).map do |item|
164
+ from_database(item)
165
+ end
166
+ end
167
+
103
168
  # Find using exciting method_missing finders attributes. Uses criteria chains under the hood to accomplish this neatness.
104
169
  #
105
170
  # @example find a user by a first name
@@ -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