dynamoid 3.5.0 → 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -4
  3. data/README.md +24 -18
  4. data/lib/dynamoid.rb +1 -0
  5. data/lib/dynamoid/adapter.rb +7 -4
  6. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +14 -11
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/batch_get_item.rb +1 -0
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb +8 -7
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +3 -2
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/backoff.rb +1 -0
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +1 -0
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/start_key.rb +1 -0
  13. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +1 -0
  14. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +1 -0
  15. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/table.rb +1 -0
  16. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/until_past_table_status.rb +1 -0
  17. data/lib/dynamoid/application_time_zone.rb +1 -0
  18. data/lib/dynamoid/associations.rb +182 -19
  19. data/lib/dynamoid/associations/association.rb +4 -2
  20. data/lib/dynamoid/associations/belongs_to.rb +2 -1
  21. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +2 -1
  22. data/lib/dynamoid/associations/has_many.rb +2 -1
  23. data/lib/dynamoid/associations/has_one.rb +2 -1
  24. data/lib/dynamoid/associations/many_association.rb +65 -22
  25. data/lib/dynamoid/associations/single_association.rb +28 -1
  26. data/lib/dynamoid/components.rb +1 -0
  27. data/lib/dynamoid/config.rb +3 -2
  28. data/lib/dynamoid/config/backoff_strategies/constant_backoff.rb +1 -0
  29. data/lib/dynamoid/config/backoff_strategies/exponential_backoff.rb +1 -0
  30. data/lib/dynamoid/config/options.rb +1 -0
  31. data/lib/dynamoid/criteria.rb +1 -0
  32. data/lib/dynamoid/criteria/chain.rb +353 -33
  33. data/lib/dynamoid/criteria/ignored_conditions_detector.rb +1 -0
  34. data/lib/dynamoid/criteria/key_fields_detector.rb +10 -1
  35. data/lib/dynamoid/criteria/nonexistent_fields_detector.rb +1 -0
  36. data/lib/dynamoid/criteria/overwritten_conditions_detector.rb +1 -0
  37. data/lib/dynamoid/dirty.rb +71 -16
  38. data/lib/dynamoid/document.rb +123 -42
  39. data/lib/dynamoid/dumping.rb +9 -0
  40. data/lib/dynamoid/dynamodb_time_zone.rb +1 -0
  41. data/lib/dynamoid/fields.rb +189 -16
  42. data/lib/dynamoid/finders.rb +65 -28
  43. data/lib/dynamoid/identity_map.rb +6 -0
  44. data/lib/dynamoid/indexes.rb +74 -15
  45. data/lib/dynamoid/log/formatter.rb +26 -0
  46. data/lib/dynamoid/middleware/identity_map.rb +1 -0
  47. data/lib/dynamoid/persistence.rb +452 -106
  48. data/lib/dynamoid/persistence/import.rb +1 -0
  49. data/lib/dynamoid/persistence/save.rb +1 -0
  50. data/lib/dynamoid/persistence/update_fields.rb +1 -0
  51. data/lib/dynamoid/persistence/upsert.rb +1 -0
  52. data/lib/dynamoid/primary_key_type_mapping.rb +1 -0
  53. data/lib/dynamoid/railtie.rb +1 -0
  54. data/lib/dynamoid/tasks/database.rb +1 -0
  55. data/lib/dynamoid/type_casting.rb +12 -0
  56. data/lib/dynamoid/undumping.rb +8 -0
  57. data/lib/dynamoid/validations.rb +2 -0
  58. data/lib/dynamoid/version.rb +1 -1
  59. metadata +7 -19
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  # The base association module which all associations include. Every association has two very important components: the source and
5
5
  # the target. The source is the object which is calling the association information. It always has the target_ids inside of an attribute on itself.
6
6
  # The target is the object which is referencing by this association.
7
+ # @private
7
8
  module Associations
9
+ # @private
8
10
  module Association
9
11
  attr_accessor :name, :options, :source, :loaded
10
12
 
@@ -116,7 +118,7 @@ module Dynamoid #:nodoc:
116
118
 
117
119
  # 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.
118
120
  #
119
- # @param [Hash] attribute hash for the new object
121
+ # @param attributes [Hash] attribute values for the new object
120
122
  #
121
123
  # @return [Dynamoid::Document] the newly-created object
122
124
  #
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  # The belongs_to association. For belongs_to, we reference only a single target instead of multiple records; that target is the
5
5
  # object to which the association object is associated.
6
6
  module Associations
7
+ # @private
7
8
  class BelongsTo
8
9
  include SingleAssociation
9
10
 
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  # The has and belongs to many association.
5
5
  module Associations
6
+ # @private
6
7
  class HasAndBelongsToMany
7
8
  include ManyAssociation
8
9
 
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  # The has_many association.
5
5
  module Associations
6
+ # @private
6
7
  class HasMany
7
8
  include ManyAssociation
8
9
 
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  # The HasOne association.
5
5
  module Associations
6
+ # @private
6
7
  class HasOne
7
8
  include Association
8
9
  include SingleAssociation
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  module Associations
5
5
  module ManyAssociation
6
6
  include Association
@@ -13,6 +13,8 @@ module Dynamoid #:nodoc:
13
13
  end
14
14
 
15
15
  include Enumerable
16
+
17
+ # @private
16
18
  # Delegate methods to the records the association represents.
17
19
  delegate :first, :last, :empty?, :size, :class, to: :records
18
20
 
@@ -20,11 +22,13 @@ module Dynamoid #:nodoc:
20
22
  #
21
23
  # @return the association records; depending on which association this is, either a single instance or an array
22
24
  #
25
+ # @private
23
26
  # @since 0.2.0
24
27
  def find_target
25
28
  Array(target_class.find(source_ids.to_a))
26
29
  end
27
30
 
31
+ # @private
28
32
  def records
29
33
  if query.empty?
30
34
  target
@@ -43,13 +47,20 @@ module Dynamoid #:nodoc:
43
47
  records.include?(object)
44
48
  end
45
49
 
46
- # Deletes an object or array of objects from the association. This removes their records from the association field on the source,
47
- # and attempts to remove the source from the target association if it is detected to exist.
50
+ # Delete an object or array of objects from the association.
51
+ #
52
+ # tag.posts.delete(post)
53
+ # tag.posts.delete([post1, post2, post3])
48
54
  #
49
- # @param [Dynamoid::Document] object the object (or array of objects) to remove from the association
55
+ # This removes their records from the association field on the source,
56
+ # and attempts to remove the source from the target association if it is
57
+ # detected to exist.
50
58
  #
51
- # @return [Dynamoid::Document] the deleted object
59
+ # It saves both models immediately - the source model and the target one
60
+ # so any not saved changes will be saved as well.
52
61
  #
62
+ # @param object [Dynamoid::Document|Array] model (or array of models) to remove from the association
63
+ # @return [Dynamoid::Document|Array] the deleted model
53
64
  # @since 0.2.0
54
65
  def delete(object)
55
66
  disassociate(Array(object).collect(&:hash_key))
@@ -59,13 +70,19 @@ module Dynamoid #:nodoc:
59
70
  object
60
71
  end
61
72
 
62
- # Add an object or array of objects to an association. This preserves the current records in the association (if any)
63
- # and adds the object to the target association if it is detected to exist.
73
+ # Add an object or array of objects to an association.
64
74
  #
65
- # @param [Dynamoid::Document] object the object (or array of objects) to add to the association
75
+ # tag.posts << post
76
+ # tag.posts << [post1, post2, post3]
77
+ #
78
+ # This preserves the current records in the association (if any) and adds
79
+ # the object to the target association if it is detected to exist.
66
80
  #
67
- # @return [Dynamoid::Document] the added object
81
+ # It saves both models immediately - the source model and the target one
82
+ # so any not saved changes will be saved as well.
68
83
  #
84
+ # @param object [Dynamoid::Document|Array] model (or array of models) to add to the association
85
+ # @return [Dynamoid::Document] the added model
69
86
  # @since 0.2.0
70
87
  def <<(object)
71
88
  associate(Array(object).collect(&:hash_key))
@@ -82,8 +99,9 @@ module Dynamoid #:nodoc:
82
99
  #
83
100
  # @param [Dynamoid::Document] object the object (or array of objects) to add to the association
84
101
  #
85
- # @return [Dynamoid::Document] the added object
102
+ # @return [Dynamoid::Document|Array] the added object
86
103
  #
104
+ # @private
87
105
  # @since 0.2.0
88
106
  def setter(object)
89
107
  target.each { |o| delete(o) }
@@ -91,23 +109,37 @@ module Dynamoid #:nodoc:
91
109
  object
92
110
  end
93
111
 
94
- # Create a new instance of the target class and add it directly to the association. If the create fails an exception will be raised.
112
+ # Create a new instance of the target class, persist it and add directly
113
+ # to the association.
95
114
  #
96
- # @param [Hash] attribute hash for the new object
115
+ # tag.posts.create!(title: 'foo')
97
116
  #
98
- # @return [Dynamoid::Document] the newly-created object
117
+ # Several models can be created at once when an array of attributes
118
+ # specified:
119
+ #
120
+ # tag.posts.create!([{ title: 'foo' }, {title: 'bar'} ])
121
+ #
122
+ # If the creation fails an exception will be raised.
99
123
  #
124
+ # @param attributes [Hash] attribute values for the new object
125
+ # @return [Dynamoid::Document|Array] the newly-created object
100
126
  # @since 0.2.0
101
127
  def create!(attributes = {})
102
128
  self << target_class.create!(attributes)
103
129
  end
104
130
 
105
- # Create a new instance of the target class and add it directly to the association.
131
+ # Create a new instance of the target class, persist it and add directly
132
+ # to the association.
106
133
  #
107
- # @param [Hash] attribute hash for the new object
134
+ # tag.posts.create(title: 'foo')
108
135
  #
109
- # @return [Dynamoid::Document] the newly-created object
136
+ # Several models can be created at once when an array of attributes
137
+ # specified:
138
+ #
139
+ # tag.posts.create([{ title: 'foo' }, {title: 'bar'} ])
110
140
  #
141
+ # @param attributes [Hash] attribute values for the new object
142
+ # @return [Dynamoid::Document|Array] the newly-created object
111
143
  # @since 0.2.0
112
144
  def create(attributes = {})
113
145
  self << target_class.create(attributes)
@@ -115,16 +147,18 @@ module Dynamoid #:nodoc:
115
147
 
116
148
  # Create a new instance of the target class and add it directly to the association. If the create fails an exception will be raised.
117
149
  #
118
- # @param [Hash] attribute hash for the new object
119
- #
120
150
  # @return [Dynamoid::Document] the newly-created object
121
151
  #
152
+ # @private
122
153
  # @since 0.2.0
123
154
  def each(&block)
124
155
  records.each(&block)
125
156
  end
126
157
 
127
- # Destroys all members of the association and removes them from the association.
158
+ # Destroys all members of the association and removes them from the
159
+ # association.
160
+ #
161
+ # tag.posts.destroy_all
128
162
  #
129
163
  # @since 0.2.0
130
164
  def destroy_all
@@ -133,7 +167,10 @@ module Dynamoid #:nodoc:
133
167
  objs.each(&:destroy)
134
168
  end
135
169
 
136
- # Deletes all members of the association and removes them from the association.
170
+ # Deletes all members of the association and removes them from the
171
+ # association.
172
+ #
173
+ # tag.posts.delete_all
137
174
  #
138
175
  # @since 0.2.0
139
176
  def delete_all
@@ -144,10 +181,13 @@ module Dynamoid #:nodoc:
144
181
 
145
182
  # Naive association filtering.
146
183
  #
147
- # @param [Hash] A hash of attributes; each must match every returned object's attribute exactly.
184
+ # tag.posts.where(title: 'foo')
148
185
  #
149
- # @return [Dynamoid::Association] the association this method was called on (for chaining purposes)
186
+ # It loads lazily all the associated models and checks provided
187
+ # conditions. That's why only equality conditions can be specified.
150
188
  #
189
+ # @param args [Hash] A hash of attributes; each must match every returned object's attribute exactly.
190
+ # @return [Dynamoid::Association] the association this method was called on (for chaining purposes)
151
191
  # @since 0.2.0
152
192
  def where(args)
153
193
  filtered = clone
@@ -167,6 +207,7 @@ module Dynamoid #:nodoc:
167
207
 
168
208
  # Delegate methods we don't find directly to the records array.
169
209
  #
210
+ # @private
170
211
  # @since 0.2.0
171
212
  def method_missing(method, *args)
172
213
  if records.respond_to?(method)
@@ -176,10 +217,12 @@ module Dynamoid #:nodoc:
176
217
  end
177
218
  end
178
219
 
220
+ # @private
179
221
  def associate(hash_key)
180
222
  source.update_attribute(source_attribute, source_ids.merge(Array(hash_key)))
181
223
  end
182
224
 
225
+ # @private
183
226
  def disassociate(hash_key)
184
227
  source.update_attribute(source_attribute, source_ids - Array(hash_key))
185
228
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  module Associations
5
5
  module SingleAssociation
6
6
  include Association
7
7
 
8
8
  delegate :class, to: :target
9
9
 
10
+ # @private
10
11
  def setter(object)
11
12
  if object.nil?
12
13
  delete
@@ -19,16 +20,37 @@ module Dynamoid #:nodoc:
19
20
  object
20
21
  end
21
22
 
23
+ # Delete a model from the association.
24
+ #
25
+ # post.logo.delete # => nil
26
+ #
27
+ # Saves both models immediately - a source model and a target one so any
28
+ # unsaved changes will be saved. Doesn't delete an associated model from
29
+ # DynamoDB.
22
30
  def delete
23
31
  target.send(target_association).disassociate(source.hash_key) if target && target_association
24
32
  disassociate
25
33
  target
26
34
  end
27
35
 
36
+ # Create a new instance of the target class, persist it and associate.
37
+ #
38
+ # post.logo.create!(hight: 50, width: 90)
39
+ #
40
+ # If the creation fails an exception will be raised.
41
+ #
42
+ # @param attributes [Hash] attributes of a model to create
43
+ # @return [Dynamoid::Document] created model
28
44
  def create!(attributes = {})
29
45
  setter(target_class.create!(attributes))
30
46
  end
31
47
 
48
+ # Create a new instance of the target class, persist it and associate.
49
+ #
50
+ # post.logo.create(hight: 50, width: 90)
51
+ #
52
+ # @param attributes [Hash] attributes of a model to create
53
+ # @return [Dynamoid::Document] created model
32
54
  def create(attributes = {})
33
55
  setter(target_class.create(attributes))
34
56
  end
@@ -44,6 +66,7 @@ module Dynamoid #:nodoc:
44
66
 
45
67
  # Delegate methods we don't find directly to the target.
46
68
  #
69
+ # @private
47
70
  # @since 0.2.0
48
71
  def method_missing(method, *args)
49
72
  if target.respond_to?(method)
@@ -53,21 +76,25 @@ module Dynamoid #:nodoc:
53
76
  end
54
77
  end
55
78
 
79
+ # @private
56
80
  def nil?
57
81
  target.nil?
58
82
  end
59
83
 
84
+ # @private
60
85
  def empty?
61
86
  # This is needed to that ActiveSupport's #blank? and #present?
62
87
  # methods work as expected for SingleAssociations.
63
88
  target.nil?
64
89
  end
65
90
 
91
+ # @private
66
92
  def associate(hash_key)
67
93
  target.send(target_association).disassociate(source.hash_key) if target && target_association
68
94
  source.update_attribute(source_attribute, Set[hash_key])
69
95
  end
70
96
 
97
+ # @private
71
98
  def disassociate(_hash_key = nil)
72
99
  source.update_attribute(source_attribute, nil)
73
100
  end
@@ -3,6 +3,7 @@
3
3
  module Dynamoid
4
4
  # All modules that a Document is composed of are defined in this
5
5
  # module, to keep the document class from getting too cluttered.
6
+ # @private
6
7
  module Components
7
8
  extend ActiveSupport::Concern
8
9
 
@@ -2,13 +2,13 @@
2
2
 
3
3
  require 'uri'
4
4
  require 'logger'
5
- require 'null_logger'
6
5
  require 'dynamoid/config/options'
7
6
  require 'dynamoid/config/backoff_strategies/constant_backoff'
8
7
  require 'dynamoid/config/backoff_strategies/exponential_backoff'
9
8
 
10
9
  module Dynamoid
11
10
  # Contains all the basic configuration information required for Dynamoid: both sensible defaults and required fields.
11
+ # @private
12
12
  module Config
13
13
  # @since 3.3.1
14
14
  DEFAULT_NAMESPACE = if defined?(Rails)
@@ -54,6 +54,7 @@ module Dynamoid
54
54
  constant: BackoffStrategies::ConstantBackoff,
55
55
  exponential: BackoffStrategies::ExponentialBackoff
56
56
  }
57
+ option :log_formatter, default: nil
57
58
  option :http_continue_timeout, default: nil # specify if you'd like to overwrite Aws Configure - default: 1
58
59
  option :http_idle_timeout, default: nil # - default: 5
59
60
  option :http_open_timeout, default: nil # - default: 15
@@ -78,7 +79,7 @@ module Dynamoid
78
79
  # @since 0.2.0
79
80
  def logger=(logger)
80
81
  case logger
81
- when false, nil then @logger = NullLogger.new
82
+ when false, nil then @logger = ::Logger.new(nil)
82
83
  when true then @logger = default_logger
83
84
  else
84
85
  @logger = logger if logger.respond_to?(:info)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Config
5
+ # @private
5
6
  module BackoffStrategies
6
7
  class ConstantBackoff
7
8
  def self.call(sec = 1)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Config
5
+ # @private
5
6
  module BackoffStrategies
6
7
  # Truncated binary exponential backoff algorithm
7
8
  # See https://en.wikipedia.org/wiki/Exponential_backoff
@@ -4,6 +4,7 @@
4
4
  module Dynamoid
5
5
  module Config
6
6
  # Encapsulates logic for setting options.
7
+ # @private
7
8
  module Options
8
9
  # Get the defaults or initialize a new empty hash.
9
10
  #
@@ -7,6 +7,7 @@ module Dynamoid
7
7
  module Criteria
8
8
  extend ActiveSupport::Concern
9
9
 
10
+ # @private
10
11
  module ClassMethods
11
12
  %i[where all first last each record_limit scan_limit batch start scan_index_forward find_by_pages project pluck].each do |meth|
12
13
  # Return a criteria chain in response to a method that will begin or end a chain. For more information,
@@ -22,26 +22,74 @@ module Dynamoid
22
22
  @consistent_read = false
23
23
  @scan_index_forward = true
24
24
 
25
- # Honor STI and :type field if it presents
26
- type = @source.inheritance_field
27
- if @source.attributes.key?(type)
28
- @query[:"#{type}.in"] = @source.deep_subclasses.map(&:name) << @source.name
29
- end
30
-
31
25
  # we should re-initialize keys detector every time we change query
32
26
  @key_fields_detector = KeyFieldsDetector.new(@query, @source)
33
27
  end
34
28
 
35
- # The workhorse method of the criteria chain. Each key in the passed in hash will become another criteria that the
36
- # ultimate query must match. A key can either be a symbol or a string, and should be an attribute name or
37
- # an attribute name with a range operator.
29
+ # Returns a chain which is a result of filtering current chain with the specified conditions.
30
+ #
31
+ # It accepts conditions in the form of a hash.
32
+ #
33
+ # Post.where(links_count: 2)
34
+ #
35
+ # A key could be either string or symbol.
36
+ #
37
+ # In order to express conditions other than equality predicates could be used.
38
+ # Predicate should be added to an attribute name to form a key +'created_at.gt' => Date.yesterday+
39
+ #
40
+ # Currently supported following predicates:
41
+ # - +gt+ - greater than
42
+ # - +gte+ - greater or equal
43
+ # - +lt+ - less than
44
+ # - +lte+ - less or equal
45
+ # - +ne+ - not equal
46
+ # - +between+ - an attribute value is greater than the first value and less than the second value
47
+ # - +in+ - check an attribute in a list of values
48
+ # - +begins_with+ - check for a prefix in string
49
+ # - +contains+ - check substring or value in a set or array
50
+ # - +not_contains+ - check for absence of substring or a value in set or array
51
+ # - +null+ - attribute doesn't exists in an item
52
+ # - +not_null+ - attribute exists in an item
53
+ #
54
+ # All the predicates match operators supported by DynamoDB's
55
+ # {ComparisonOperator}[https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html#DDB-Type-Condition-ComparisonOperator]
56
+ #
57
+ # Post.where('size.gt' => 1000)
58
+ # Post.where('size.gte' => 1000)
59
+ # Post.where('size.lt' => 35000)
60
+ # Post.where('size.lte' => 35000)
61
+ # Post.where('author.ne' => 'John Doe')
62
+ # Post.where('created_at.between' => [Time.now - 3600, Time.now])
63
+ # Post.where('category.in' => ['tech', 'fashion'])
64
+ # Post.where('title.begins_with' => 'How long')
65
+ # Post.where('tags.contains' => 'Ruby')
66
+ # Post.where('tags.not_contains' => 'Ruby on Rails')
67
+ # Post.where('legacy_attribute.null' => true)
68
+ # Post.where('optional_attribute.not_null' => true)
69
+ #
70
+ # There are some limitations for a sort key. Only following predicates
71
+ # are supported - +gt+, +gte+, +lt+, +lte+, +between+, +begins_with+.
72
+ #
73
+ # +where+ without argument will return the current chain.
74
+ #
75
+ # Multiple calls can be chained together and conditions will be merged:
76
+ #
77
+ # Post.where('size.gt' => 1000).where('title' => 'some title')
38
78
  #
39
- # @example A simple criteria
40
- # where(:name => 'Josh')
79
+ # It's equivalent to:
41
80
  #
42
- # @example A more complicated criteria
43
- # where(:name => 'Josh', 'created_at.gt' => DateTime.now - 1.day)
81
+ # Post.where('size.gt' => 1000, 'title' => 'some title')
44
82
  #
83
+ # But only one condition can be specified for a certain attribute. The
84
+ # last specified condition will override all the others. Only condition
85
+ # 'size.lt' => 200 will be used in following examples:
86
+ #
87
+ # Post.where('size.gt' => 100, 'size.lt' => 200)
88
+ # Post.where('size.gt' => 100).where('size.lt' => 200)
89
+ #
90
+ # Internally +where+ performs either +Scan+ or +Query+ operation.
91
+ #
92
+ # @return [Dynamoid::Criteria::Chain]
45
93
  # @since 0.2.0
46
94
  def where(args)
47
95
  detector = IgnoredConditionsDetector.new(args)
@@ -67,6 +115,13 @@ module Dynamoid
67
115
  self
68
116
  end
69
117
 
118
+ # Turns on strongly consistent reads.
119
+ #
120
+ # By default reads are eventually consistent.
121
+ #
122
+ # Post.where('size.gt' => 1000).consistent
123
+ #
124
+ # @return [Dynamoid::Criteria::Chain]
70
125
  def consistent
71
126
  @consistent_read = true
72
127
  self
@@ -74,11 +129,39 @@ module Dynamoid
74
129
 
75
130
  # Returns all the records matching the criteria.
76
131
  #
132
+ # Since +where+ and most of the other methods return a +Chain+
133
+ # the only way to get a result as a collection is to call the +all+
134
+ # method. It returns +Enumerator+ which could be used directly or
135
+ # transformed into +Array+
136
+ #
137
+ # Post.all # => Enumerator
138
+ # Post.where(links_count: 2).all # => Enumerator
139
+ # Post.where(links_count: 2).all.to_a # => Array
140
+ #
141
+ # When the result set is too large DynamoDB divides it into separate
142
+ # pages. While an enumerator iterates over the result models each page
143
+ # is loaded lazily. So even an extra large result set can be loaded and
144
+ # processed with considerably small memory footprint and throughput
145
+ # consumption.
146
+ #
147
+ # @return [Enumerator::Lazy]
77
148
  # @since 0.2.0
78
149
  def all
79
150
  records
80
151
  end
81
152
 
153
+ # Returns the actual number of items in a table matching the criteria.
154
+ #
155
+ # Post.where(links_count: 2).count
156
+ #
157
+ # Internally it uses either `Scan` or `Query` DynamoDB's operation so it
158
+ # costs like all the matching items were read from a table.
159
+ #
160
+ # The only difference is that items are read by DynemoDB but not actually
161
+ # loaded on the client side. DynamoDB returns only count of items after
162
+ # filtering.
163
+ #
164
+ # @return [Integer]
82
165
  def count
83
166
  if @key_fields_detector.key_present?
84
167
  count_via_query
@@ -87,16 +170,61 @@ module Dynamoid
87
170
  end
88
171
  end
89
172
 
90
- # Returns the last fetched record matched the criteria
91
- # Enumerable doesn't implement `last`, only `first`
92
- # So we have to implement it ourselves
173
+ # Returns the first item matching the criteria.
174
+ #
175
+ # Post.where(links_count: 2).first
176
+ #
177
+ # Applies `record_limit(1)` to ensure only a single record is fetched
178
+ # when no non-key conditions are present and `scan_limit(1)` when no
179
+ # conditions are present at all.
180
+ #
181
+ # If used without criteria it just returns the first item of some
182
+ # arbitrary order.
183
+ #
184
+ # Post.first
185
+ #
186
+ # @return [Model|nil]
187
+ def first(*args)
188
+ n = args.first || 1
189
+
190
+ return scan_limit(n).to_a.first(*args) if @query.blank?
191
+ return super if @key_fields_detector.non_key_present?
192
+
193
+ record_limit(n).to_a.first(*args)
194
+ end
195
+
196
+ # Returns the last item matching the criteria.
197
+ #
198
+ # Post.where(links_count: 2).last
93
199
  #
200
+ # DynamoDB doesn't support ordering by some arbitrary attribute except a
201
+ # sort key. So this method is mostly useful during development and
202
+ # testing.
203
+ #
204
+ # If used without criteria it just returns the last item of some arbitrary order.
205
+ #
206
+ # Post.last
207
+ #
208
+ # It isn't efficient from the performance point of view as far as it reads and
209
+ # loads all the filtered items from DynamoDB.
210
+ #
211
+ # @return [Model|nil]
94
212
  def last
95
213
  all.to_a.last
96
214
  end
97
215
 
98
- # Destroys all the records matching the criteria.
216
+ # Deletes all the items matching the criteria.
99
217
  #
218
+ # Post.where(links_count: 2).delete_all
219
+ #
220
+ # If called without criteria then it deletes all the items in a table.
221
+ #
222
+ # Post.delete_all
223
+ #
224
+ # It loads all the items either with +Scan+ or +Query+ operation and
225
+ # deletes them in batch with +BatchWriteItem+ operation. +BatchWriteItem+
226
+ # is limited by request size and items count so it's quite possible the
227
+ # deletion will require several +BatchWriteItem+ calls.
100
228
  def delete_all
101
229
  ids = []
102
230
  ranges = []
@@ -117,53 +245,215 @@ module Dynamoid
117
245
  end
118
246
  alias destroy_all delete_all
119
247
 
120
- # The record limit is the limit of evaluated records returned by the
121
- # query or scan.
248
+ # Set the record limit.
249
+ #
250
+ # The record limit is the limit of evaluated items returned by the
251
+ # +Query+ or +Scan+. In other words it's how many items should be
252
+ # returned in response.
253
+ #
254
+ # Post.where(links_count: 2).record_limit(1000) # => 1000 models
255
+ # Post.record_limit(1000) # => 1000 models
256
+ #
257
+ # It could be very inefficient in terms of HTTP requests in pathological
258
+ # cases. DynamoDB doesn't support out of the box the limits for items
259
+ # count after filtering. So it's possible to make a lot of HTTP requests
260
+ # to find items matching criteria and skip not matching. It means that
261
+ # the cost (read capacity units) is unpredictable.
262
+ #
263
+ # Because of such issues with performance and cost it's mostly useful in
264
+ # development and testing.
265
+ #
266
+ # When called without criteria it works like +scan_limit+.
267
+ #
268
+ # @return [Dynamoid::Criteria::Chain]
122
269
  def record_limit(limit)
123
270
  @record_limit = limit
124
271
  self
125
272
  end
126
273
 
127
- # The scan limit which is the limit of records that DynamoDB will
128
- # internally query or scan. This is different from the record limit
129
- # as with filtering DynamoDB may look at N scanned records but return 0
130
- # records if none pass the filter.
274
+ # Set the scan limit.
275
+ #
276
+ # The scan limit is the limit of records that DynamoDB will internally
277
+ # read with +Query+ or +Scan+. It's different from the record limit as
278
+ # with filtering DynamoDB may look at N scanned items but return 0
279
+ # items if none passes the filter. So it can return less items than was
280
+ # specified with the limit.
281
+ #
282
+ # Post.where(links_count: 2).scan_limit(1000) # => 850 models
283
+ # Post.scan_limit(1000) # => 1000 models
284
+ #
285
+ # By contrast with +record_limit+ the cost (read capacity units) and
286
+ # performance is predictable.
287
+ #
288
+ # When called without criteria it works like +record_limit+.
289
+ #
290
+ # @return [Dynamoid::Criteria::Chain]
131
291
  def scan_limit(limit)
132
292
  @scan_limit = limit
133
293
  self
134
294
  end
135
295
 
296
+ # Set the batch size.
297
+ #
298
+ # The batch size is a number of items which will be lazily loaded one by one.
299
+ # When the batch size is set then items will be loaded batch by batch of
300
+ # the specified size instead of relying on the default paging mechanism
301
+ # of DynamoDB.
302
+ #
303
+ # Post.where(links_count: 2).batch(1000).all.each do |post|
304
+ # # process a post
305
+ # end
306
+ #
307
+ # It's useful to limit memory usage or throughput consumption
308
+ #
309
+ # @return [Dynamoid::Criteria::Chain]
136
310
  def batch(batch_size)
137
311
  @batch_size = batch_size
138
312
  self
139
313
  end
140
314
 
315
+ # Set the start item.
316
+ #
317
+ # When the start item is set the items will be loaded starting right
318
+ # after the specified item.
319
+ #
320
+ # Post.where(links_count: 2).start(post)
321
+ #
322
+ # It can be used to implement an own pagination mechanism.
323
+ #
324
+ # Post.where(author_id: author_id).start(last_post).scan_limit(50)
325
+ #
326
+ # The specified start item will not be returned back in a result set.
327
+ #
328
+ # Actually it doesn't need all the item attributes to start - an item may
329
+ # have only the primary key attributes (partition and sort key if it's
330
+ # declared).
331
+ #
332
+ # Post.where(links_count: 2).start(Post.new(id: id))
333
+ #
334
+ # It also supports a +Hash+ argument with the keys attributes - a
335
+ # partition key and a sort key (if it's declared).
336
+ #
337
+ # Post.where(links_count: 2).start(id: id)
338
+ #
339
+ # @return [Dynamoid::Criteria::Chain]
141
340
  def start(start)
142
341
  @start = start
143
342
  self
144
343
  end
145
344
 
345
+ # Reverse the sort order.
346
+ #
347
+ # By default the sort order is ascending (by the sort key value). Set a
348
+ # +false+ value to reverse the order.
349
+ #
350
+ # Post.where(id: id, 'views_count.gt' => 1000).scan_index_forward(false)
351
+ #
352
+ # It works only for queries with a partition key condition e.g. +id:
353
+ # 'some-id'+ which internally performs +Query+ operation.
354
+ #
355
+ # @return [Dynamoid::Criteria::Chain]
146
356
  def scan_index_forward(scan_index_forward)
147
357
  @scan_index_forward = scan_index_forward
148
358
  self
149
359
  end
150
360
 
151
- # Allows you to use the results of a search as an enumerable over the results found.
361
+ # Allows to use the results of a search as an enumerable over the results
362
+ # found.
363
+ #
364
+ # Post.each do |post|
365
+ # end
366
+ #
367
+ # Post.all.each do |post|
368
+ # end
369
+ #
370
+ # Post.where(links_count: 2).each do |post|
371
+ # end
372
+ #
373
+ # It works similar to the +all+ method so results are loaded lazily.
152
374
  #
153
375
  # @since 0.2.0
154
376
  def each(&block)
155
377
  records.each(&block)
156
378
  end
157
379
 
380
+ # Iterates over the pages returned by DynamoDB.
381
+ #
382
+ # DynamoDB has its own paging machanism and divides a large result set
383
+ # into separate pages. The +find_by_pages+ method provides access to
384
+ # these native DynamoDB pages.
385
+ #
386
+ # The pages are loaded lazily.
387
+ #
388
+ # Post.where('views_count.gt' => 1000).find_by_pages do |posts, options|
389
+ # # process posts
390
+ # end
391
+ #
392
+ # It passes as block argument an +Array+ of models and a Hash with options.
393
+ #
394
+ # Options +Hash+ contains only one option +:last_evaluated_key+. The last
395
+ # evaluated key is a Hash with key attributes of the last item processed by
396
+ # DynamoDB. It can be used to resume querying using the +start+ method.
397
+ #
398
+ # posts, options = Post.where('views_count.gt' => 1000).find_by_pages.first
399
+ # last_key = options[:last_evaluated_key]
400
+ #
401
+ # # ...
402
+ #
403
+ # Post.where('views_count.gt' => 1000).start(last_key).find_by_pages do |posts, options|
404
+ # end
405
+ #
406
+ # If it's called without a block then it returns an +Enumerator+.
407
+ #
408
+ # enum = Post.where('views_count.gt' => 1000).find_by_pages
409
+ #
410
+ # enum.each do |posts, options|
411
+ # # process posts
412
+ # end
413
+ #
414
+ # @return [Enumerator::Lazy]
158
415
  def find_by_pages(&block)
159
416
  pages.each(&block)
160
417
  end
161
418
 
419
+ # Select only specified fields.
420
+ #
421
+ # It takes one or more field names and returns a collection of models with only
422
+ # these fields set.
423
+ #
424
+ # Post.where('views_count.gt' => 1000).select(:title)
425
+ # Post.where('views_count.gt' => 1000).select(:title, :created_at)
426
+ # Post.select(:id)
427
+ #
428
+ # It can be used to avoid loading large field values and to decrease a
429
+ # memory footprint.
430
+ #
431
+ # @return [Dynamoid::Criteria::Chain]
162
432
  def project(*fields)
163
433
  @project = fields.map(&:to_sym)
164
434
  self
165
435
  end
166
436
 
437
+ # Select only specified fields.
438
+ #
439
+ # It takes one or more field names and returns an array of either values
440
+ # or arrays of values.
441
+ #
442
+ # Post.pluck(:id) # => ['1', '2']
443
+ # Post.pluck(:title, :title) # => [['1', 'Title #1'], ['2', 'Title#2']]
444
+ #
445
+ # Post.where('views_count.gt' => 1000).pluck(:title)
446
+ #
447
+ # There are some differences between +pluck+ and +project+. +pluck+
448
+ # - doesn't instantiate models
449
+ # - it isn't chainable and returns +Array+ instead of +Chain+
450
+ #
451
+ # It deserializes values if a field type isn't supported by DynamoDB natively.
452
+ #
453
+ # It can be used to avoid loading large field values and to decrease a
454
+ # memory footprint.
455
+ #
456
+ # @return [Array]
167
457
  def pluck(*args)
168
458
  fields = args.map(&:to_sym)
169
459
  @project = fields
@@ -325,6 +615,13 @@ module Dynamoid
325
615
 
326
616
  def range_query
327
617
  opts = {}
618
+ query = self.query
619
+
620
+ # Honor STI and :type field if it presents
621
+ if @source.attributes.key?(@source.inheritance_field) &&
622
+ @key_fields_detector.hash_key.to_sym != @source.inheritance_field.to_sym
623
+ query.update(sti_condition)
624
+ end
328
625
 
329
626
  # Add hash key
330
627
  opts[:hash_key] = @key_fields_detector.hash_key
@@ -332,15 +629,7 @@ module Dynamoid
332
629
 
333
630
  # Add range key
334
631
  if @key_fields_detector.range_key
335
- opts[:range_key] = @key_fields_detector.range_key
336
- if query[@key_fields_detector.range_key].present?
337
- value = type_cast_condition_parameter(@key_fields_detector.range_key, query[@key_fields_detector.range_key])
338
- opts.update(range_eq: value)
339
- end
340
-
341
- query.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key|
342
- opts.merge!(range_hash(key))
343
- end
632
+ add_range_key_to_range_query(query, opts)
344
633
  end
345
634
 
346
635
  (query.keys.map(&:to_sym) - [@key_fields_detector.hash_key.to_sym, @key_fields_detector.range_key.try(:to_sym)])
@@ -357,6 +646,18 @@ module Dynamoid
357
646
  opts.merge(query_opts).merge(consistent_opts)
358
647
  end
359
648
 
649
+ def add_range_key_to_range_query(query, opts)
650
+ opts[:range_key] = @key_fields_detector.range_key
651
+ if query[@key_fields_detector.range_key].present?
652
+ value = type_cast_condition_parameter(@key_fields_detector.range_key, query[@key_fields_detector.range_key])
653
+ opts.update(range_eq: value)
654
+ end
655
+
656
+ query.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key|
657
+ opts.merge!(range_hash(key))
658
+ end
659
+ end
660
+
360
661
  # TODO: casting should be operator aware
361
662
  # e.g. for NULL operator value should be boolean
362
663
  # and isn't related to an attribute own type
@@ -418,6 +719,13 @@ module Dynamoid
418
719
  end
419
720
 
420
721
  def scan_query
722
+ query = self.query
723
+
724
+ # Honor STI and :type field if it presents
725
+ if sti_condition
726
+ query.update(sti_condition)
727
+ end
728
+
421
729
  {}.tap do |opts|
422
730
  query.keys.map(&:to_sym).each do |key|
423
731
  if key.to_s.include?('.')
@@ -440,6 +748,18 @@ module Dynamoid
440
748
  opts[:project] = @project
441
749
  opts
442
750
  end
751
+
752
+ def sti_condition
753
+ condition = {}
754
+ type = @source.inheritance_field
755
+
756
+ if @source.attributes.key?(type)
757
+ class_names = @source.deep_subclasses.map(&:name) << @source.name
758
+ condition[:"#{type}.in"] = class_names
759
+ end
760
+
761
+ condition
762
+ end
443
763
  end
444
764
  end
445
765
  end