dynamoid 3.2.0 → 3.3.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.
@@ -4,9 +4,10 @@ module Dynamoid
4
4
  module AdapterPlugin
5
5
  class AwsSdkV3
6
6
  class UntilPastTableStatus
7
- attr_reader :table_name, :status
7
+ attr_reader :client, :table_name, :status
8
8
 
9
- def initialize(table_name, status = :creating)
9
+ def initialize(client, table_name, status = :creating)
10
+ @client = client
10
11
  @table_name = table_name
11
12
  @status = status
12
13
  end
@@ -17,21 +17,24 @@ module Dynamoid
17
17
  after_initialize :set_inheritance_field
18
18
  end
19
19
 
20
- include ActiveModel::AttributeMethods
20
+ include ActiveModel::AttributeMethods # Actually it will be inclided in Dirty module again
21
21
  include ActiveModel::Conversion
22
22
  include ActiveModel::MassAssignmentSecurity if defined?(ActiveModel::MassAssignmentSecurity)
23
23
  include ActiveModel::Naming
24
24
  include ActiveModel::Observing if defined?(ActiveModel::Observing)
25
25
  include ActiveModel::Serializers::JSON
26
26
  include ActiveModel::Serializers::Xml if defined?(ActiveModel::Serializers::Xml)
27
+ include Dynamoid::Persistence
28
+ include Dynamoid::Loadable
29
+ # Dirty module should be included after Persistence and Loadable
30
+ # because it overrides some methods declared in these modules
31
+ include Dynamoid::Dirty
27
32
  include Dynamoid::Fields
28
33
  include Dynamoid::Indexes
29
- include Dynamoid::Persistence
30
34
  include Dynamoid::Finders
31
35
  include Dynamoid::Associations
32
36
  include Dynamoid::Criteria
33
37
  include Dynamoid::Validations
34
38
  include Dynamoid::IdentityMap
35
- include Dynamoid::Dirty
36
39
  end
37
40
  end
@@ -31,6 +31,7 @@ module Dynamoid
31
31
  option :sync_retry_max_times, default: 60 # a bit over 2 minutes
32
32
  option :sync_retry_wait_seconds, default: 2
33
33
  option :convert_big_decimal, default: false
34
+ option :store_attribute_with_nil_value, default: false # keep or ignore attribute with nil value at saving
34
35
  option :models_dir, default: './app/models' # perhaps you keep your dynamoid models in a different directory?
35
36
  option :application_timezone, default: :utc # available values - :utc, :local, time zone name like "Hawaii"
36
37
  option :dynamodb_timezone, default: :utc # available values - :utc, :local, time zone name like "Hawaii"
@@ -8,7 +8,7 @@ module Dynamoid
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  module ClassMethods
11
- %i[where all first last each record_limit scan_limit batch start scan_index_forward find_by_pages].each do |meth|
11
+ %i[where all first last each record_limit scan_limit batch start scan_index_forward find_by_pages project].each do |meth|
12
12
  # Return a criteria chain in response to a method that will begin or end a chain. For more information,
13
13
  # see Dynamoid::Criteria::Chain.
14
14
  #
@@ -159,6 +159,11 @@ module Dynamoid
159
159
  pages.each(&block)
160
160
  end
161
161
 
162
+ def project(*fields)
163
+ @project = fields.map(&:to_sym)
164
+ self
165
+ end
166
+
162
167
  private
163
168
 
164
169
  # The actual records referenced by the association.
@@ -190,9 +195,12 @@ module Dynamoid
190
195
  #
191
196
  # @since 3.1.0
192
197
  def pages_via_query
193
- Enumerator.new do |yielder|
198
+ Enumerator.new do |y|
194
199
  Dynamoid.adapter.query(source.table_name, range_query).each do |items, metadata|
195
- yielder.yield items.map { |hash| source.from_database(hash) }, metadata.slice(:last_evaluated_key)
200
+ page = items.map { |h| source.from_database(h) }
201
+ options = metadata.slice(:last_evaluated_key)
202
+
203
+ y.yield page, options
196
204
  end
197
205
  end
198
206
  end
@@ -203,9 +211,12 @@ module Dynamoid
203
211
  #
204
212
  # @since 3.1.0
205
213
  def pages_via_scan
206
- Enumerator.new do |yielder|
214
+ Enumerator.new do |y|
207
215
  Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).each do |items, metadata|
208
- yielder.yield(items.map { |hash| source.from_database(hash) }, metadata.slice(:last_evaluated_key))
216
+ page = items.map { |h| source.from_database(h) }
217
+ options = metadata.slice(:last_evaluated_key)
218
+
219
+ y.yield page, options
209
220
  end
210
221
  end
211
222
  end
@@ -271,6 +282,13 @@ module Dynamoid
271
282
  { contains: val }
272
283
  when 'not_contains'
273
284
  { not_contains: val }
285
+ # NULL/NOT_NULL operators don't have parameters
286
+ # So { null: true } means NULL check and { null: false } means NOT_NULL one
287
+ # The same logic is used for { not_null: BOOL }
288
+ when 'null'
289
+ val ? { null: nil } : { not_null: nil }
290
+ when 'not_null'
291
+ val ? { not_null: nil } : { null: nil }
274
292
  end
275
293
 
276
294
  { name.to_sym => hash }
@@ -314,10 +332,15 @@ module Dynamoid
314
332
  opts.merge(query_opts).merge(consistent_opts)
315
333
  end
316
334
 
335
+ # TODO casting should be operator aware
336
+ # e.g. for NULL operator value should be boolean
337
+ # and isn't related to an attribute own type
317
338
  def type_cast_condition_parameter(key, value)
318
339
  return value if %i[array set].include?(source.attributes[key.to_sym][:type])
319
340
 
320
- if !value.respond_to?(:to_ary)
341
+ if [true, false].include?(value) # Support argument for null/not_null operators
342
+ value
343
+ elsif !value.respond_to?(:to_ary)
321
344
  options = source.attributes[key.to_sym]
322
345
  value_casted = TypeCasting.cast_field(value, options)
323
346
  Dumping.dump_field(value_casted, options)
@@ -356,13 +379,16 @@ module Dynamoid
356
379
 
357
380
  def query_opts
358
381
  opts = {}
382
+ # Don't specify select = ALL_ATTRIBUTES option explicitly because it's
383
+ # already a default value of Select statement. Explicite Select value
384
+ # conflicts with AttributesToGet statement (project option).
359
385
  opts[:index_name] = @key_fields_detector.index_name if @key_fields_detector.index_name
360
- opts[:select] = 'ALL_ATTRIBUTES'
361
386
  opts[:record_limit] = @record_limit if @record_limit
362
387
  opts[:scan_limit] = @scan_limit if @scan_limit
363
388
  opts[:batch_size] = @batch_size if @batch_size
364
389
  opts[:exclusive_start_key] = start_key if @start
365
390
  opts[:scan_index_forward] = @scan_index_forward
391
+ opts[:project] = @project
366
392
  opts
367
393
  end
368
394
 
@@ -386,6 +412,7 @@ module Dynamoid
386
412
  opts[:batch_size] = @batch_size if @batch_size
387
413
  opts[:exclusive_start_key] = start_key if @start
388
414
  opts[:consistent_read] = true if @consistent_read
415
+ opts[:project] = @project
389
416
  opts
390
417
  end
391
418
  end
@@ -3,57 +3,126 @@
3
3
  module Dynamoid #:nodoc:
4
4
  module Criteria
5
5
  class KeyFieldsDetector
6
- attr_reader :hash_key, :range_key, :index_name
6
+
7
+ class Query
8
+ def initialize(query_hash)
9
+ @query_hash = query_hash
10
+ @fields_with_operator = query_hash.keys.map(&:to_s)
11
+ @fields = query_hash.keys.map(&:to_s).map { |s| s.split('.').first }
12
+ end
13
+
14
+ def contain?(field_name)
15
+ @fields.include?(field_name.to_s)
16
+ end
17
+
18
+ def contain_with_eq_operator?(field_name)
19
+ @fields_with_operator.include?(field_name.to_s)
20
+ end
21
+ end
7
22
 
8
23
  def initialize(query, source)
9
24
  @query = query
10
25
  @source = source
11
-
12
- detect_keys
26
+ @query = Query.new(query)
27
+ @result = find_keys_in_query
13
28
  end
14
29
 
15
30
  def key_present?
16
- @hash_key.present?
31
+ @result.present?
32
+ end
33
+
34
+ def hash_key
35
+ @result && @result[:hash_key]
36
+ end
37
+
38
+ def range_key
39
+ @result && @result[:range_key]
40
+ end
41
+
42
+ def index_name
43
+ @result && @result[:index_name]
17
44
  end
18
45
 
19
46
  private
20
47
 
21
- def detect_keys
22
- query_keys = @query.keys.collect { |k| k.to_s.split('.').first }
48
+ def find_keys_in_query
49
+ match_table_and_sort_key ||
50
+ match_local_secondary_index ||
51
+ match_global_secondary_index_and_sort_key ||
52
+ match_table ||
53
+ match_global_secondary_index
54
+ end
55
+
56
+ # Use table's default range key
57
+ def match_table_and_sort_key
58
+ return unless @query.contain_with_eq_operator?(@source.hash_key)
59
+ return unless @source.range_key
23
60
 
24
- # See if querying based on table hash key
25
- if @query.keys.map(&:to_s).include?(@source.hash_key.to_s)
26
- @hash_key = @source.hash_key
61
+ if @query.contain?(@source.range_key)
62
+ {
63
+ hash_key: @source.hash_key,
64
+ range_key: @source.range_key
65
+ }
66
+ end
67
+ end
27
68
 
28
- # Use table's default range key
29
- if query_keys.include?(@source.range_key.to_s)
30
- @range_key = @source.range_key
31
- return
32
- end
69
+ # See if can use any local secondary index range key
70
+ # Chooses the first LSI found that can be utilized for the query
71
+ def match_local_secondary_index
72
+ return unless @query.contain_with_eq_operator?(@source.hash_key)
33
73
 
34
- # See if can use any local secondary index range key
35
- # Chooses the first LSI found that can be utilized for the query
36
- @source.local_secondary_indexes.each do |_, lsi|
37
- next unless query_keys.include?(lsi.range_key.to_s)
74
+ lsi = @source.local_secondary_indexes.values.find do |lsi|
75
+ @query.contain?(lsi.range_key)
76
+ end
77
+
78
+ if lsi.present?
79
+ {
80
+ hash_key: @source.hash_key,
81
+ range_key: lsi.range_key,
82
+ index_name: lsi.name,
83
+ }
84
+ end
85
+ end
38
86
 
39
- @range_key = lsi.range_key
40
- @index_name = lsi.name
41
- end
87
+ # See if can use any global secondary index
88
+ # Chooses the first GSI found that can be utilized for the query
89
+ # GSI with range key involved into query conditions has higher priority
90
+ # But only do so if projects ALL attributes otherwise we won't
91
+ # get back full data
92
+ def match_global_secondary_index_and_sort_key
93
+ gsi = @source.global_secondary_indexes.values.find do |gsi|
94
+ @query.contain_with_eq_operator?(gsi.hash_key) && gsi.projected_attributes == :all &&
95
+ @query.contain?(gsi.range_key)
96
+ end
42
97
 
43
- return
98
+ if gsi.present?
99
+ {
100
+ hash_key: gsi.hash_key,
101
+ range_key: gsi.range_key,
102
+ index_name: gsi.name,
103
+ }
44
104
  end
105
+ end
106
+
107
+ def match_table
108
+ return unless @query.contain_with_eq_operator?(@source.hash_key)
45
109
 
46
- # See if can use any global secondary index
47
- # Chooses the first GSI found that can be utilized for the query
48
- # But only do so if projects ALL attributes otherwise we won't
49
- # get back full data
50
- @source.global_secondary_indexes.each do |_, gsi|
51
- next unless @query.keys.map(&:to_s).include?(gsi.hash_key.to_s) && gsi.projected_attributes == :all
52
- next if @range_key.present? && !query_keys.include?(gsi.range_key.to_s)
110
+ {
111
+ hash_key: @source.hash_key,
112
+ }
113
+ end
114
+
115
+ def match_global_secondary_index
116
+ gsi = @source.global_secondary_indexes.values.find do |gsi|
117
+ @query.contain_with_eq_operator?(gsi.hash_key) && gsi.projected_attributes == :all
118
+ end
53
119
 
54
- @hash_key = gsi.hash_key
55
- @range_key = gsi.range_key
56
- @index_name = gsi.name
120
+ if gsi.present?
121
+ {
122
+ hash_key: gsi.hash_key,
123
+ range_key: gsi.range_key,
124
+ index_name: gsi.name,
125
+ }
57
126
  end
58
127
  end
59
128
  end
@@ -1,67 +1,219 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynamoid
4
+ # Support interface of Rails' ActiveModel::Dirty module
5
+ #
6
+ # The reason why not just include ActiveModel::Dirty -
7
+ # ActiveModel::Dirty conflicts either with @attributes or
8
+ # #attributes in different Rails versions.
9
+ #
10
+ # Separate implementation (or copy-pasting) is the best way to
11
+ # avoid endless monkey-patching
12
+ #
13
+ # Documentation:
14
+ # https://api.rubyonrails.org/v4.2/classes/ActiveModel/Dirty.html
4
15
  module Dirty
5
16
  extend ActiveSupport::Concern
6
- include ActiveModel::Dirty
17
+ include ActiveModel::AttributeMethods
18
+
19
+ included do
20
+ attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
21
+ attribute_method_suffix '_previously_changed?', '_previous_change'
22
+ attribute_method_affix prefix: 'restore_', suffix: '!'
23
+ end
7
24
 
8
25
  module ClassMethods
26
+ def update_fields(*)
27
+ if model = super
28
+ model.send(:clear_changes_information)
29
+ end
30
+ model
31
+ end
32
+
33
+ def upsert(*)
34
+ if model = super
35
+ model.send(:clear_changes_information)
36
+ end
37
+ model
38
+ end
39
+
9
40
  def from_database(*)
10
- super.tap { |d| d.send(:clear_changes_information) }
41
+ super.tap do |m|
42
+ m.send(:clear_changes_information)
43
+ end
11
44
  end
12
45
  end
13
46
 
14
47
  def save(*)
15
- clear_changes { super }
48
+ if status = super
49
+ changes_applied
50
+ end
51
+ status
16
52
  end
17
53
 
18
- def update!(*)
19
- ret = super
20
- clear_changes # update! completely reloads all fields on the class, so any extant changes are wiped out
21
- ret
54
+ def save!(*)
55
+ super.tap do
56
+ changes_applied
57
+ end
22
58
  end
23
59
 
24
- def reload
25
- super.tap { clear_changes }
60
+ def update(*)
61
+ super.tap do
62
+ clear_changes_information
63
+ end
26
64
  end
27
65
 
28
- def clear_changes
29
- previous = changes
30
- (block_given? ? yield : true).tap do |result|
31
- unless result == false # failed validation; nil is OK.
32
- @previously_changed = previous
33
- clear_changes_information
34
- end
66
+ def update!(*)
67
+ super.tap do
68
+ clear_changes_information
35
69
  end
36
70
  end
37
71
 
38
- def write_attribute(name, value)
39
- attribute_will_change!(name) unless read_attribute(name) == value
40
- super
72
+ def reload(*)
73
+ super.tap do
74
+ clear_changes_information
75
+ end
41
76
  end
42
77
 
43
- protected
78
+ # Returns +true+ if any attribute have unsaved changes, +false+ otherwise.
79
+ #
80
+ # person.changed? # => false
81
+ # person.name = 'bob'
82
+ # person.changed? # => true
83
+ def changed?
84
+ changed_attributes.present?
85
+ end
44
86
 
45
- def attribute_method?(attr)
46
- super || self.class.attributes.key?(attr.to_sym)
87
+ # Returns an array with the name of the attributes with unsaved changes.
88
+ #
89
+ # person.changed # => []
90
+ # person.name = 'bob'
91
+ # person.changed # => ["name"]
92
+ def changed
93
+ changed_attributes.keys
47
94
  end
48
95
 
49
- if ActiveModel::VERSION::STRING >= '5.2.0'
50
- # The ActiveModel::Dirty API was changed
51
- # https://github.com/rails/rails/commit/c3675f50d2e59b7fc173d7b332860c4b1a24a726#diff-aaddd42c7feb0834b1b5c66af69814d3
52
- # So we just try to disable new functionality
96
+ # Returns a hash of changed attributes indicating their original
97
+ # and new values like <tt>attr => [original value, new value]</tt>.
98
+ #
99
+ # person.changes # => {}
100
+ # person.name = 'bob'
101
+ # person.changes # => { "name" => ["bill", "bob"] }
102
+ def changes
103
+ ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
104
+ end
53
105
 
54
- def mutations_from_database
55
- @mutations_from_database ||= ActiveModel::NullMutationTracker.instance
56
- end
106
+ # Returns a hash of attributes that were changed before the model was saved.
107
+ #
108
+ # person.name # => "bob"
109
+ # person.name = 'robert'
110
+ # person.save
111
+ # person.previous_changes # => {"name" => ["bob", "robert"]}
112
+ def previous_changes
113
+ @previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new
114
+ end
57
115
 
58
- def forget_attribute_assignments; end
116
+ # Returns a hash of the attributes with unsaved changes indicating their original
117
+ # values like <tt>attr => original value</tt>.
118
+ #
119
+ # person.name # => "bob"
120
+ # person.name = 'robert'
121
+ # person.changed_attributes # => {"name" => "bob"}
122
+ def changed_attributes
123
+ @changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new
59
124
  end
60
125
 
61
- if ActiveModel::VERSION::STRING < '4.2.0'
62
- def clear_changes_information
63
- changed_attributes.clear
64
- end
126
+ # Handle <tt>*_changed?</tt> for +method_missing+.
127
+ def attribute_changed?(attr, options = {}) #:nodoc:
128
+ result = changes_include?(attr)
129
+ result &&= options[:to] == __send__(attr) if options.key?(:to)
130
+ result &&= options[:from] == changed_attributes[attr] if options.key?(:from)
131
+ result
132
+ end
133
+
134
+ # Handle <tt>*_was</tt> for +method_missing+.
135
+ def attribute_was(attr) # :nodoc:
136
+ attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr)
137
+ end
138
+
139
+ # Restore all previous data of the provided attributes.
140
+ def restore_attributes(attributes = changed)
141
+ attributes.each { |attr| restore_attribute! attr }
142
+ end
143
+
144
+ # Handles <tt>*_previously_changed?</tt> for +method_missing+.
145
+ def attribute_previously_changed?(attr) #:nodoc:
146
+ previous_changes_include?(attr)
65
147
  end
148
+
149
+ # Handles <tt>*_previous_change</tt> for +method_missing+.
150
+ def attribute_previous_change(attr)
151
+ previous_changes[attr] if attribute_previously_changed?(attr)
152
+ end
153
+
154
+ private
155
+
156
+ def changes_include?(attr_name)
157
+ attributes_changed_by_setter.include?(attr_name)
158
+ end
159
+ alias attribute_changed_by_setter? changes_include?
160
+
161
+ # Removes current changes and makes them accessible through +previous_changes+.
162
+ def changes_applied # :doc:
163
+ @previously_changed = changes
164
+ @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
165
+ end
166
+
167
+ # Clear all dirty data: current changes and previous changes.
168
+ def clear_changes_information # :doc:
169
+ @previously_changed = ActiveSupport::HashWithIndifferentAccess.new
170
+ @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
171
+ end
172
+
173
+ # Handle <tt>*_change</tt> for +method_missing+.
174
+ def attribute_change(attr)
175
+ [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
176
+ end
177
+
178
+ # Handle <tt>*_will_change!</tt> for +method_missing+.
179
+ def attribute_will_change!(attr)
180
+ return if attribute_changed?(attr)
181
+
182
+ begin
183
+ value = __send__(attr)
184
+ value = value.duplicable? ? value.clone : value
185
+ rescue TypeError, NoMethodError
186
+ end
187
+
188
+ set_attribute_was(attr, value)
189
+ end
190
+
191
+ # Handle <tt>restore_*!</tt> for +method_missing+.
192
+ def restore_attribute!(attr)
193
+ if attribute_changed?(attr)
194
+ __send__("#{attr}=", changed_attributes[attr])
195
+ clear_attribute_changes([attr])
196
+ end
197
+ end
198
+
199
+ # Returns +true+ if attr_name were changed before the model was saved,
200
+ # +false+ otherwise.
201
+ def previous_changes_include?(attr_name)
202
+ previous_changes.include?(attr_name)
203
+ end
204
+
205
+ # This is necessary because `changed_attributes` might be overridden in
206
+ # other implemntations (e.g. in `ActiveRecord`)
207
+ alias_method :attributes_changed_by_setter, :changed_attributes # :nodoc:
208
+
209
+ # Force an attribute to have a particular "before" value
210
+ def set_attribute_was(attr, old_value)
211
+ attributes_changed_by_setter[attr] = old_value
212
+ end
213
+
214
+ # Remove changes information for the provided attributes.
215
+ def clear_attribute_changes(attributes) # :doc:
216
+ attributes_changed_by_setter.except!(*attributes)
217
+ end
66
218
  end
67
219
  end