dynamoid 0.3.2 → 0.4.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.
Files changed (40) hide show
  1. data/Dynamoid.gemspec +3 -2
  2. data/README.markdown +39 -3
  3. data/VERSION +1 -1
  4. data/lib/dynamoid.rb +2 -0
  5. data/lib/dynamoid/adapter.rb +9 -8
  6. data/lib/dynamoid/adapter/aws_sdk.rb +15 -9
  7. data/lib/dynamoid/adapter/local.rb +39 -14
  8. data/lib/dynamoid/associations.rb +5 -6
  9. data/lib/dynamoid/associations/association.rb +23 -1
  10. data/lib/dynamoid/associations/belongs_to.rb +0 -1
  11. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +0 -1
  12. data/lib/dynamoid/associations/has_many.rb +0 -1
  13. data/lib/dynamoid/associations/many_association.rb +10 -7
  14. data/lib/dynamoid/associations/single_association.rb +2 -1
  15. data/lib/dynamoid/components.rb +1 -0
  16. data/lib/dynamoid/config.rb +1 -0
  17. data/lib/dynamoid/criteria.rb +2 -2
  18. data/lib/dynamoid/criteria/chain.rb +118 -43
  19. data/lib/dynamoid/document.rb +58 -6
  20. data/lib/dynamoid/fields.rb +18 -3
  21. data/lib/dynamoid/finders.rb +14 -7
  22. data/lib/dynamoid/indexes.rb +3 -2
  23. data/lib/dynamoid/indexes/index.rb +2 -2
  24. data/lib/dynamoid/persistence.rb +29 -14
  25. data/spec/app/models/address.rb +4 -0
  26. data/spec/app/models/camel_case.rb +13 -0
  27. data/spec/app/models/tweet.rb +9 -0
  28. data/spec/dynamoid/adapter/aws_sdk_spec.rb +5 -5
  29. data/spec/dynamoid/adapter/local_spec.rb +79 -79
  30. data/spec/dynamoid/adapter_spec.rb +6 -6
  31. data/spec/dynamoid/associations/association_spec.rb +26 -12
  32. data/spec/dynamoid/criteria/chain_spec.rb +64 -21
  33. data/spec/dynamoid/criteria_spec.rb +28 -0
  34. data/spec/dynamoid/document_spec.rb +29 -0
  35. data/spec/dynamoid/fields_spec.rb +5 -0
  36. data/spec/dynamoid/finders_spec.rb +6 -1
  37. data/spec/dynamoid/indexes/index_spec.rb +1 -1
  38. data/spec/dynamoid/persistence_spec.rb +9 -17
  39. data/spec/spec_helper.rb +1 -0
  40. metadata +4 -3
@@ -3,6 +3,7 @@ module Dynamoid #:nodoc:
3
3
 
4
4
  module Associations
5
5
  module SingleAssociation
6
+ include Association
6
7
 
7
8
  delegate :class, :to => :target
8
9
 
@@ -59,7 +60,7 @@ module Dynamoid #:nodoc:
59
60
  # @return [Dynamoid::Document] the found target (or nil if nothing)
60
61
  #
61
62
  # @since 0.2.0
62
- def target
63
+ def find_target
63
64
  return if source_ids.empty?
64
65
  target_class.find(source_ids.first)
65
66
  end
@@ -16,6 +16,7 @@ module Dynamoid
16
16
  before_save :set_updated_at
17
17
  end
18
18
 
19
+ include ActiveModel::AttributeMethods
19
20
  include ActiveModel::Conversion
20
21
  include ActiveModel::Dirty
21
22
  include ActiveModel::MassAssignmentSecurity
@@ -21,6 +21,7 @@ module Dynamoid
21
21
  option :warn_on_scan, :default => true
22
22
  option :partitioning, :default => false
23
23
  option :partition_size, :default => 200
24
+ option :endpoint, :default => 'dynamodb.us-east-1.amazonaws.com'
24
25
  option :included_models, :default => []
25
26
 
26
27
  # The default logger for Dynamoid: either the Rails logger or just stdout.
@@ -9,7 +9,7 @@ module Dynamoid
9
9
 
10
10
  module ClassMethods
11
11
 
12
- [:where, :all, :first, :each].each do |meth|
12
+ [:where, :all, :first, :each, :limit, :start].each do |meth|
13
13
  # Return a criteria chain in response to a method that will begin or end a chain. For more information,
14
14
  # see Dynamoid::Criteria::Chain.
15
15
  #
@@ -26,4 +26,4 @@ module Dynamoid
26
26
  end
27
27
  end
28
28
 
29
- end
29
+ end
@@ -6,20 +6,21 @@ module Dynamoid #:nodoc:
6
6
  # chain to relation). It is a chainable object that builds up a query and eventually executes it either on an index
7
7
  # or by a full table scan.
8
8
  class Chain
9
- attr_accessor :query, :source, :index, :values
9
+ attr_accessor :query, :source, :index, :values, :limit, :start, :consistent_read
10
10
  include Enumerable
11
-
11
+
12
12
  # Create a new criteria chain.
13
13
  #
14
14
  # @param [Class] source the class upon which the ultimate query will be performed.
15
15
  def initialize(source)
16
16
  @query = {}
17
17
  @source = source
18
+ @consistent_read = false
18
19
  end
19
-
20
- # The workhorse method of the criteria chain. Each key in the passed in hash will become another criteria that the
21
- # ultimate query must match. A key can either be a symbol or a string, and should be an attribute name or
22
- # an attribute name with a range operator.
20
+
21
+ # The workhorse method of the criteria chain. Each key in the passed in hash will become another criteria that the
22
+ # ultimate query must match. A key can either be a symbol or a string, and should be an attribute name or
23
+ # an attribute name with a range operator.
23
24
  #
24
25
  # @example A simple criteria
25
26
  # where(:name => 'Josh')
@@ -32,7 +33,12 @@ module Dynamoid #:nodoc:
32
33
  args.each {|k, v| query[k] = v}
33
34
  self
34
35
  end
35
-
36
+
37
+ def consistent
38
+ @consistent_read = true
39
+ self
40
+ end
41
+
36
42
  # Returns all the records matching the criteria.
37
43
  #
38
44
  # @since 0.2.0
@@ -42,40 +48,59 @@ module Dynamoid #:nodoc:
42
48
 
43
49
  # Returns the first record matching the criteria.
44
50
  #
45
- # @since 0.2.0
51
+ # @since 0.2.0
46
52
  def first
47
- records.first
53
+ limit(1).first
54
+ end
55
+
56
+ def limit(limit)
57
+ @limit = limit
58
+ records
59
+ end
60
+
61
+ def start(start)
62
+ @start = start
63
+ self
48
64
  end
49
65
 
50
66
  # Allows you to use the results of a search as an enumerable over the results found.
51
67
  #
52
- # @since 0.2.0
68
+ # @since 0.2.0
53
69
  def each(&block)
54
70
  records.each(&block)
55
71
  end
56
-
72
+
73
+ def consistent_opts
74
+ { :consistent_read => consistent_read }
75
+ end
76
+
57
77
  private
58
-
78
+
59
79
  # The actual records referenced by the association.
60
80
  #
61
81
  # @return [Array] an array of the found records.
62
82
  #
63
83
  # @since 0.2.0
64
84
  def records
65
- return records_with_index if index
66
- records_without_index
85
+ if range?
86
+ records_with_range
87
+ elsif index
88
+ records_with_index
89
+ else
90
+ records_without_index
91
+ end
67
92
  end
68
93
 
69
94
  # If the query matches an index on the associated class, then this method will retrieve results from the index table.
70
95
  #
71
96
  # @return [Array] an array of the found records.
72
97
  #
73
- # @since 0.2.0
98
+ # @since 0.2.0
74
99
  def records_with_index
75
100
  ids = if index.range_key?
76
101
  Dynamoid::Adapter.query(index.table_name, index_query).collect{|r| r[:ids]}.inject(Set.new) {|set, result| set + result}
77
102
  else
78
- results = Dynamoid::Adapter.read(index.table_name, index_query[:hash_value])
103
+ results = Dynamoid::Adapter.read(index.table_name, index_query[:hash_value], consistent_opts)
79
104
  if results
80
105
  results[:ids]
81
106
  else
@@ -85,28 +110,40 @@ module Dynamoid #:nodoc:
85
110
  if ids.nil? || ids.empty?
86
111
  []
87
112
  else
88
- Array(source.find(ids.to_a))
113
+ ids = ids.to_a
114
+
115
+ if @start
116
+ ids = ids.drop_while { |id| id != @start.hash_key }.drop(1)
117
+ end
118
+
119
+ ids = ids.take(@limit) if @limit
120
+ Array(source.find(ids, consistent_opts))
89
121
  end
90
122
  end
91
-
92
- # If the query does not match an index, we'll manually scan the associated table to manually find results.
123
+
124
+ def records_with_range
125
+ Dynamoid::Adapter.query(source.table_name, range_query).collect {|hash| source.new(hash).tap { |r| r.new_record = false } }
126
+ end
127
+
128
+ # If the query does not match an index, we'll manually scan the associated table to find results.
93
129
  #
94
130
  # @return [Array] an array of the found records.
95
131
  #
96
- # @since 0.2.0
132
+ # @since 0.2.0
97
133
  def records_without_index
98
134
  if Dynamoid::Config.warn_on_scan
99
135
  Dynamoid.logger.warn 'Queries without an index are forced to use scan and are generally much slower than indexed queries!'
100
136
  Dynamoid.logger.warn "You can index this query by adding this to #{source.to_s.downcase}.rb: index [#{source.attributes.sort.collect{|attr| ":#{attr}"}.join(', ')}]"
101
137
  end
102
- Dynamoid::Adapter.scan(source.table_name, query).collect {|hash| source.new(hash).tap { |r| r.new_record = false } }
138
+
139
+ Dynamoid::Adapter.scan(source.table_name, query, query_opts).collect {|hash| source.new(hash).tap { |r| r.new_record = false } }
103
140
  end
104
-
105
- # Format the provided query so that it can be used to query results from DynamoDB.
141
+
142
+ # Format the provided query so that it can be used to query results from DynamoDB.
106
143
  #
107
144
  # @return [Hash] a hash with keys of :hash_value and :range_value
108
145
  #
109
- # @since 0.2.0
146
+ # @since 0.2.0
110
147
  def index_query
111
148
  values = index.values(query)
112
149
  {}.tap do |hash|
@@ -114,21 +151,7 @@ module Dynamoid #:nodoc:
114
151
  if index.range_key?
115
152
  key = query.keys.find{|k| k.to_s.include?('.')}
116
153
  if key
117
- if query[key].is_a?(Range)
118
- hash[:range_value] = query[key]
119
- else
120
- val = query[key].to_f
121
- case key.split('.').last
122
- when 'gt'
123
- hash[:range_greater_than] = val
124
- when 'lt'
125
- hash[:range_less_than] = val
126
- when 'gte'
127
- hash[:range_gte] = val
128
- when 'lte'
129
- hash[:range_lte] = val
130
- end
131
- end
154
+ hash.merge!(range_hash(key))
132
155
  else
133
156
  raise Dynamoid::Errors::MissingRangeKey, 'This index requires a range key'
134
157
  end
@@ -136,16 +159,68 @@ module Dynamoid #:nodoc:
136
159
  end
137
160
  end
138
161
 
162
+ def range_hash(key)
163
+ val = query[key]
164
+
165
+ return { :range_value => query[key] } if query[key].is_a?(Range)
166
+
167
+ case key.split('.').last
168
+ when 'gt'
169
+ { :range_greater_than => val.to_f }
170
+ when 'lt'
171
+ { :range_less_than => val.to_f }
172
+ when 'gte'
173
+ { :range_gte => val.to_f }
174
+ when 'lte'
175
+ { :range_lte => val.to_f }
176
+ when 'begins_with'
177
+ { :range_begins_with => val }
178
+ end
179
+ end
180
+
181
+ def range_query
182
+ opts = { :hash_value => query[source.hash_key] }
183
+ if key = query.keys.find { |k| k.to_s.include?('.') }
184
+ opts.merge!(range_key(key))
185
+ end
186
+ opts.merge(query_opts).merge(consistent_opts)
187
+ end
188
+
139
189
  # Return an index that fulfills all the attributes the criteria is querying, or nil if none is found.
140
190
  #
141
- # @since 0.2.0
191
+ # @since 0.2.0
142
192
  def index
143
- index = source.find_index(query.keys.collect{|k| k.to_s.split('.').first})
193
+ index = source.find_index(query_keys)
144
194
  return nil if index.blank?
145
195
  index
146
196
  end
197
+
198
+ def query_keys
199
+ query.keys.collect{|k| k.to_s.split('.').first}
200
+ end
201
+
202
+ def range?
203
+ return false unless source.range_key
204
+ query_keys == ['id'] || (query_keys.to_set == ['id', source.range_key.to_s].to_set)
205
+ end
206
+
207
+ def start_key
208
+ key = { :hash_key_element => { 'S' => @start.hash_key } }
209
+ if range_key = @start.class.range_key
210
+ range_key_type = @start.class.attributes[range_key][:type] == :string ? 'S' : 'N'
211
+ key.merge!({:range_key_element => { range_key_type => @start.send(range_key) } })
212
+ end
213
+ key
214
+ end
215
+
216
+ def query_opts
217
+ opts = {}
218
+ opts[:limit] = @limit if @limit
219
+ opts[:next_token] = start_key if @start
220
+ opts
221
+ end
147
222
  end
148
-
223
+
149
224
  end
150
-
225
+
151
226
  end
@@ -8,10 +8,47 @@ module Dynamoid #:nodoc:
8
8
  include Dynamoid::Components
9
9
 
10
10
  included do
11
+ class_attribute :options
12
+ self.options = {}
13
+
11
14
  Dynamoid::Config.included_models << self
12
15
  end
13
16
 
14
17
  module ClassMethods
18
+ # Set up table options, including naming it whatever you want, setting the id key, and manually overriding read and
19
+ # write capacity.
20
+ #
21
+ # @param [Hash] options options to pass for this table
22
+ # @option options [Symbol] :name the name for the table; this still gets namespaced
23
+ # @option options [Symbol] :id id column for the table
24
+ # @option options [Integer] :read_capacity set the read capacity for the table; does not work on existing tables
25
+ # @option options [Integer] :write_capacity set the write capacity for the table; does not work on existing tables
26
+ #
27
+ # @since 0.4.0
28
+ def table(options = {})
29
+ self.options = options
30
+ end
31
+
32
+ # Returns the read_capacity for this table.
33
+ #
34
+ # @since 0.4.0
35
+ def read_capacity
36
+ options[:read_capacity] || Dynamoid::Config.read_capacity
37
+ end
38
+
39
+ # Returns the write_capacity for this table.
40
+ #
41
+ # @since 0.4.0
42
+ def write_capacity
43
+ options[:write_capacity] || Dynamoid::Config.write_capacity
44
+ end
45
+
46
+ # Returns the id field for this class.
47
+ #
48
+ # @since 0.4.0
49
+ def hash_key
50
+ options[:key] || :id
51
+ end
15
52
 
16
53
  # Initialize a new object and immediately save it to the database.
17
54
  #
@@ -66,13 +103,13 @@ module Dynamoid #:nodoc:
66
103
  #
67
104
  # @since 0.2.0
68
105
  def initialize(attrs = {})
106
+ self.class.send(:field, self.class.hash_key) unless self.respond_to?(self.class.hash_key)
107
+
69
108
  @new_record = true
70
109
  @attributes ||= {}
71
- incoming_attributes = self.class.undump(attrs)
110
+ @associations ||= {}
72
111
 
73
- self.class.attributes.keys.each do |attribute|
74
- send "#{attribute}=", incoming_attributes[attribute]
75
- end
112
+ self.class.undump(attrs).each {|key, value| send "#{key}=", value }
76
113
  end
77
114
 
78
115
  # An object is equal to another object if their ids are equal.
@@ -80,7 +117,7 @@ module Dynamoid #:nodoc:
80
117
  # @since 0.2.0
81
118
  def ==(other)
82
119
  return false if other.nil?
83
- other.respond_to?(:id) && other.id == self.id
120
+ other.respond_to?(:hash_key) && other.hash_key == self.hash_key
84
121
  end
85
122
 
86
123
  # Reload an object from the database -- if you suspect the object has changed in the datastore and you need those
@@ -90,9 +127,24 @@ module Dynamoid #:nodoc:
90
127
  #
91
128
  # @since 0.2.0
92
129
  def reload
93
- self.attributes = self.class.find(self.id).attributes
130
+ self.attributes = self.class.find(self.hash_key).attributes
131
+ @associations.values.each(&:reset)
94
132
  self
95
133
  end
134
+
135
+ # Return an object's hash key, regardless of what it might be called to the object.
136
+ #
137
+ # @since 0.4.0
138
+ def hash_key
139
+ self.send(self.class.hash_key)
140
+ end
141
+
142
+ # Assign an object's hash key, regardless of what it might be called to the object.
143
+ #
144
+ # @since 0.4.0
145
+ def hash_key=(key)
146
+ self.send("#{self.class.hash_key}=".to_sym, key)
147
+ end
96
148
  end
97
149
 
98
150
  end
@@ -9,9 +9,10 @@ module Dynamoid #:nodoc:
9
9
  # Initialize the attributes we know the class has, in addition to our magic attributes: id, created_at, and updated_at.
10
10
  included do
11
11
  class_attribute :attributes
12
-
12
+ class_attribute :range_key
13
+
13
14
  self.attributes = {}
14
- field :id
15
+
15
16
  field :created_at, :datetime
16
17
  field :updated_at, :datetime
17
18
  end
@@ -34,7 +35,12 @@ module Dynamoid #:nodoc:
34
35
  define_method("#{named}?") { !read_attribute(named).nil? }
35
36
  define_method("#{named}=") {|value| write_attribute(named, value) }
36
37
 
37
- define_attribute_method(name)
38
+ respond_to?(:define_attribute_method) ? define_attribute_method(name) : define_attribute_methods([name])
39
+ end
40
+
41
+ def range(name, type = :string)
42
+ field(name, type)
43
+ self.range_key = name
38
44
  end
39
45
  end
40
46
 
@@ -49,7 +55,16 @@ module Dynamoid #:nodoc:
49
55
  #
50
56
  # @since 0.2.0
51
57
  def write_attribute(name, value)
58
+ if (size = value.to_s.size) > MAX_ITEM_SIZE
59
+ Dynamoid.logger.warn "DynamoDB can't store items larger than #{MAX_ITEM_SIZE} and the #{name} field has a length of #{size}."
60
+ end
61
+
52
62
  attribute_will_change!(name) unless self.read_attribute(name) == value
63
+
64
+ if association = @associations[name]
65
+ association.reset
66
+ end
67
+
53
68
  attributes[name.to_sym] = value
54
69
  end
55
70
  alias :[]= :write_attribute
@@ -15,12 +15,19 @@ module Dynamoid
15
15
  # @return [Dynamoid::Document] one object or an array of objects, depending on whether the input was an array or not
16
16
  #
17
17
  # @since 0.2.0
18
- def find(*id)
19
- id = Array(id.flatten.uniq)
20
- if id.count == 1
21
- self.find_by_id(id.first)
18
+ def find(*ids)
19
+
20
+ options = if ids.last.is_a? Hash
21
+ ids.slice!(-1)
22
+ else
23
+ {}
24
+ end
25
+
26
+ ids = Array(ids.flatten.uniq)
27
+ if ids.count == 1
28
+ self.find_by_id(ids.first, options)
22
29
  else
23
- items = Dynamoid::Adapter.read(self.table_name, id)
30
+ items = Dynamoid::Adapter.read(self.table_name, ids, options)
24
31
  items[self.table_name].collect{|i| self.build(i).tap { |o| o.new_record = false } }
25
32
  end
26
33
  end
@@ -32,8 +39,8 @@ module Dynamoid
32
39
  # @return [Dynamoid::Document] the found object, or nil if nothing was found
33
40
  #
34
41
  # @since 0.2.0
35
- def find_by_id(id)
36
- if item = Dynamoid::Adapter.read(self.table_name, id)
42
+ def find_by_id(id, options = {})
43
+ if item = Dynamoid::Adapter.read(self.table_name, id, options)
37
44
  obj = self.new(item)
38
45
  obj.new_record = false
39
46
  return obj