dynamoid 1.1.0 → 1.2.0

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.
@@ -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