dynamoid 3.2.0 → 3.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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