dynamoid 3.7.0 → 3.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -8,16 +8,6 @@ module Dynamoid
8
8
  module Fields
9
9
  extend ActiveSupport::Concern
10
10
 
11
- # @private
12
- # Types allowed in indexes:
13
- PERMITTED_KEY_TYPES = %i[
14
- number
15
- integer
16
- string
17
- datetime
18
- serialized
19
- ].freeze
20
-
21
11
  # Initialize the attributes we know the class has, in addition to our magic attributes: id, created_at, and updated_at.
22
12
  included do
23
13
  class_attribute :attributes, instance_accessor: false
@@ -219,20 +209,26 @@ module Dynamoid
219
209
  #
220
210
  # @since 0.4.0
221
211
  def table(options)
212
+ self.options = options
213
+
222
214
  # a default 'id' column is created when Dynamoid::Document is included
223
215
  unless attributes.key? hash_key
224
216
  remove_field :id
225
217
  field(hash_key)
226
218
  end
227
219
 
220
+ # The created_at/updated_at fields are declared in the `included` callback first.
221
+ # At that moment the only known setting is `Dynamoid::Config.timestamps`.
222
+ # Now `options[:timestamps]` may override the global setting for a model.
223
+ # So we need to make decision again and declare the fields or rollback thier declaration.
224
+ #
225
+ # Do not replace with `#timestamps_enabled?`.
228
226
  if options[:timestamps] && !Dynamoid::Config.timestamps
229
- # Timestamp fields weren't declared in `included` hook because they
230
- # are disabled globaly
227
+ # The fields weren't declared in `included` callback because they are disabled globaly
231
228
  field :created_at, :datetime
232
229
  field :updated_at, :datetime
233
230
  elsif options[:timestamps] == false && Dynamoid::Config.timestamps
234
- # Timestamp fields were declared in `included` hook but they are
235
- # disabled for a table
231
+ # The fields were declared in `included` callback but they are disabled for a table
236
232
  remove_field :created_at
237
233
  remove_field :updated_at
238
234
  end
@@ -289,10 +285,12 @@ module Dynamoid
289
285
  #
290
286
  # @param name [Symbol] the name of the field
291
287
  # @param value [Object] the value to assign to that field
288
+ # @return [Dynamoid::Document] self
292
289
  #
293
290
  # @since 0.2.0
294
291
  def write_attribute(name, value)
295
292
  name = name.to_sym
293
+ old_value = read_attribute(name)
296
294
 
297
295
  unless attribute_is_present_on_model?(name)
298
296
  raise Dynamoid::Errors::UnknownAttribute.new("Attribute #{name} is not part of the model")
@@ -302,12 +300,13 @@ module Dynamoid
302
300
  association.reset
303
301
  end
304
302
 
305
- attribute_will_change!(name) # Dirty API
306
-
307
303
  @attributes_before_type_cast[name] = value
308
304
 
309
305
  value_casted = TypeCasting.cast_field(value, self.class.attributes[name])
306
+ attribute_will_change!(name) if old_value != value_casted # Dirty API
307
+
310
308
  attributes[name] = value_casted
309
+ self
311
310
  end
312
311
  alias []= write_attribute
313
312
 
@@ -367,7 +366,7 @@ module Dynamoid
367
366
  # @since 0.2.0
368
367
  def set_updated_at
369
368
  # @_touch_record=false means explicit disabling
370
- if self.class.timestamps_enabled? && !updated_at_changed? && @_touch_record != false
369
+ if self.class.timestamps_enabled? && changed? && !updated_at_changed? && @_touch_record != false
371
370
  self.updated_at = DateTime.now.in_time_zone(Time.zone)
372
371
  end
373
372
  end
@@ -4,6 +4,15 @@ module Dynamoid
4
4
  module Indexes
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ # @private
8
+ # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey
9
+ # Types allowed in indexes
10
+ PERMITTED_KEY_DYNAMODB_TYPES = %i[
11
+ string
12
+ binary
13
+ number
14
+ ].freeze
15
+
7
16
  included do
8
17
  class_attribute :local_secondary_indexes, instance_accessor: false
9
18
  class_attribute :global_secondary_indexes, instance_accessor: false
@@ -20,17 +29,17 @@ module Dynamoid
20
29
  #
21
30
  # field :category
22
31
  #
23
- # global_secondary_indexes hash_key: :category
32
+ # global_secondary_index hash_key: :category
24
33
  # end
25
34
  #
26
35
  # The full example with all the options being specified:
27
36
  #
28
- # global_secondary_indexes hash_key: :category,
29
- # range_key: :created_at,
30
- # name: 'posts_category_created_at_index',
31
- # projected_attributes: :all,
32
- # read_capacity: 100,
33
- # write_capacity: 20
37
+ # global_secondary_index hash_key: :category,
38
+ # range_key: :created_at,
39
+ # name: 'posts_category_created_at_index',
40
+ # projected_attributes: :all,
41
+ # read_capacity: 100,
42
+ # write_capacity: 20
34
43
  #
35
44
  # Global secondary index should be declared after fields for mentioned
36
45
  # hash key and optional range key are declared (with method +field+)
@@ -86,14 +95,14 @@ module Dynamoid
86
95
  # range :created_at, :datetime
87
96
  # field :author_id
88
97
  #
89
- # local_secondary_indexes hash_key: :author_id
98
+ # local_secondary_index range_key: :author_id
90
99
  # end
91
100
  #
92
101
  # The full example with all the options being specified:
93
102
  #
94
- # local_secondary_indexes range_key: :created_at,
95
- # name: 'posts_created_at_index',
96
- # projected_attributes: :all
103
+ # local_secondary_index range_key: :created_at,
104
+ # name: 'posts_created_at_index',
105
+ # projected_attributes: :all
97
106
  #
98
107
  # Local secondary index should be declared after fields for mentioned
99
108
  # hash key and optional range key are declared (with method +field+) as
@@ -290,39 +299,37 @@ module Dynamoid
290
299
  end
291
300
  end
292
301
 
302
+
303
+ def validate_hash_key
304
+ validate_index_key(:hash_key, @hash_key)
305
+ end
306
+
293
307
  def validate_range_key
294
- if @range_key.present?
295
- range_field_attributes = @dynamoid_class.attributes[@range_key]
296
- if range_field_attributes.present?
297
- range_key_type = range_field_attributes[:type]
298
- if Dynamoid::Fields::PERMITTED_KEY_TYPES.include?(range_key_type)
299
- @range_key_schema = {
300
- @range_key => PrimaryKeyTypeMapping.dynamodb_type(range_key_type, range_field_attributes)
301
- }
302
- else
303
- errors.add(:range_key, 'Index :range_key is not a valid key type')
304
- end
305
- else
306
- errors.add(:range_key, "No such field #{@range_key} defined on table")
307
- end
308
- end
308
+ validate_index_key(:range_key, @range_key)
309
309
  end
310
310
 
311
- def validate_hash_key
312
- hash_field_attributes = @dynamoid_class.attributes[@hash_key]
313
- if hash_field_attributes.present?
314
- hash_field_type = hash_field_attributes[:type]
315
- if Dynamoid::Fields::PERMITTED_KEY_TYPES.include?(hash_field_type)
316
- @hash_key_schema = {
317
- @hash_key => PrimaryKeyTypeMapping.dynamodb_type(hash_field_type, hash_field_attributes)
318
- }
319
- else
320
- errors.add(:hash_key, 'Index :hash_key is not a valid key type')
321
- end
311
+ def validate_index_key(key_param, key_val)
312
+ return if key_val.blank?
313
+
314
+ key_field_attributes = @dynamoid_class.attributes[key_val]
315
+ if key_field_attributes.blank?
316
+ errors.add(key_param, "No such field #{key_val} defined on table")
317
+ return
318
+ end
319
+
320
+ key_dynamodb_type = dynamodb_type(key_field_attributes[:type], key_field_attributes)
321
+ if PERMITTED_KEY_DYNAMODB_TYPES.include?(key_dynamodb_type)
322
+ self.send("#{key_param}_schema=", { key_val => key_dynamodb_type })
322
323
  else
323
- errors.add(:hash_key, "No such field #{@hash_key} defined on table")
324
+ errors.add(key_param, "Index :#{key_param} is not a valid key type")
324
325
  end
325
326
  end
327
+
328
+ def dynamodb_type(field_type, options)
329
+ PrimaryKeyTypeMapping.dynamodb_type(field_type, options)
330
+ rescue Errors::UnsupportedKeyType
331
+ field_type
332
+ end
326
333
  end
327
334
  end
328
335
  end
@@ -8,12 +8,14 @@ module Dynamoid
8
8
  attrs.each do |key, value|
9
9
  send("#{key}=", value) if respond_to?("#{key}=")
10
10
  end
11
+
12
+ self
11
13
  end
12
14
 
13
15
  # Reload an object from the database -- if you suspect the object has changed in the data store and you need those
14
16
  # changes to be reflected immediately, you would call this method. This is a consistent read.
15
17
  #
16
- # @return [Dynamoid::Document] the document this method was called on
18
+ # @return [Dynamoid::Document] self
17
19
  #
18
20
  # @since 0.2.0
19
21
  def reload
@@ -24,7 +26,10 @@ module Dynamoid
24
26
  end
25
27
 
26
28
  self.attributes = self.class.find(hash_key, **options).attributes
29
+
27
30
  @associations.values.each(&:reset)
31
+ @new_record = false
32
+
28
33
  self
29
34
  end
30
35
  end
@@ -19,7 +19,7 @@ module Dynamoid
19
19
  def call
20
20
  UpdateValidations.validate_attributes_exist(@model_class, @attributes)
21
21
 
22
- if Dynamoid::Config.timestamps
22
+ if @model_class.timestamps_enabled?
23
23
  @attributes[:updated_at] ||= DateTime.now.in_time_zone(Time.zone)
24
24
  end
25
25
 
@@ -19,7 +19,7 @@ module Dynamoid
19
19
  def call
20
20
  UpdateValidations.validate_attributes_exist(@model_class, @attributes)
21
21
 
22
- if Dynamoid::Config.timestamps
22
+ if @model_class.timestamps_enabled?
23
23
  @attributes[:updated_at] ||= DateTime.now.in_time_zone(Time.zone)
24
24
  end
25
25
 
@@ -112,8 +112,10 @@ module Dynamoid
112
112
 
113
113
  if created_successfuly && self.options[:expires]
114
114
  attribute = self.options[:expires][:field]
115
- Dynamoid.adapter.update_time_to_live(table_name, attribute)
115
+ Dynamoid.adapter.update_time_to_live(options[:table_name], attribute)
116
116
  end
117
+
118
+ self
117
119
  end
118
120
 
119
121
  # Deletes the table for the model.
@@ -122,8 +124,10 @@ module Dynamoid
122
124
  # is deleted completely.
123
125
  #
124
126
  # Subsequent method calls for the same table will be ignored.
127
+ # @return [Model class] self
125
128
  def delete_table
126
129
  Dynamoid.adapter.delete_table(table_name)
130
+ self
127
131
  end
128
132
 
129
133
  # @private
@@ -365,6 +369,9 @@ module Dynamoid
365
369
  #
366
370
  # User.inc('1', 'Tylor', age: 2)
367
371
  #
372
+ # It's an atomic operation it does not interfere with other write
373
+ # requests.
374
+ #
368
375
  # Uses efficient low-level +UpdateItem+ operation and does only one HTTP
369
376
  # request.
370
377
  #
@@ -374,6 +381,7 @@ module Dynamoid
374
381
  # @param hash_key_value [Scalar value] hash key
375
382
  # @param range_key_value [Scalar value] range key (optional)
376
383
  # @param counters [Hash] value to increase by
384
+ # @return [Model class] self
377
385
  def inc(hash_key_value, range_key_value = nil, counters)
378
386
  options = if range_key
379
387
  value_casted = TypeCasting.cast_field(range_key_value, attributes[range_key])
@@ -391,6 +399,8 @@ module Dynamoid
391
399
  t.add(k => value_dumped)
392
400
  end
393
401
  end
402
+
403
+ self
394
404
  end
395
405
  end
396
406
 
@@ -405,11 +415,13 @@ module Dynamoid
405
415
  # user.touch(:last_login_at)
406
416
  #
407
417
  # @param name [Symbol] attribute name to update (optional)
418
+ # @return [Dynamoid::Document] self
408
419
  def touch(name = nil)
409
420
  now = DateTime.now
410
421
  self.updated_at = now
411
422
  attributes[name] = now if name
412
423
  save
424
+ self
413
425
  end
414
426
 
415
427
  # Is this object persisted in DynamoDB?
@@ -479,13 +491,9 @@ module Dynamoid
479
491
 
480
492
  @_touch_record = options[:touch]
481
493
 
482
- if new_record?
483
- run_callbacks(:create) do
484
- run_callbacks(:save) do
485
- Save.call(self)
486
- end
487
- end
488
- else
494
+ create_or_update = new_record? ? :create : :update
495
+
496
+ run_callbacks(create_or_update) do
489
497
  run_callbacks(:save) do
490
498
  Save.call(self)
491
499
  end
@@ -538,10 +546,13 @@ module Dynamoid
538
546
  # @param attribute [Symbol] attribute name to update
539
547
  # @param value [Object] the value to assign it
540
548
  # @return [Dynamoid::Document] self
549
+ #
541
550
  # @since 0.2.0
542
551
  def update_attribute(attribute, value)
552
+ # final implementation is in the Dynamoid::Validation module
543
553
  write_attribute(attribute, value)
544
554
  save
555
+ self
545
556
  end
546
557
 
547
558
  # Update a model.
@@ -555,7 +566,7 @@ module Dynamoid
555
566
  # collections if attribute is a collection (one of +array+, +set+ or
556
567
  # +map+).
557
568
  #
558
- # user.update do |t|
569
+ # user.update! do |t|
559
570
  # t.add(age: 1, followers_count: 5)
560
571
  # t.add(hobbies: ['skying', 'climbing'])
561
572
  # end
@@ -563,24 +574,28 @@ module Dynamoid
563
574
  # Operation +delete+ is applied to collection attribute types and
564
575
  # substructs one collection from another.
565
576
  #
566
- # user.update do |t|
577
+ # user.update! do |t|
567
578
  # t.delete(hobbies: ['skying'])
568
579
  # end
569
580
  #
570
581
  # Operation +set+ just changes an attribute value:
571
582
  #
572
- # user.update do |t|
583
+ # user.update! do |t|
573
584
  # t.set(age: 21)
574
585
  # end
575
586
  #
576
- # All the operations works like +ADD+, +DELETE+ and +PUT+ actions supported
587
+ # All the operations work like +ADD+, +DELETE+ and +PUT+ actions supported
577
588
  # by +AttributeUpdates+
578
589
  # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
579
590
  # of +UpdateItem+ operation.
580
591
  #
592
+ # It's an atomic operation. So adding or deleting elements in a collection
593
+ # or incrementing or decrementing a numeric field is atomic and does not
594
+ # interfere with other write requests.
595
+ #
581
596
  # Can update a model conditionaly:
582
597
  #
583
- # user.update(if: { age: 20 }) do |t|
598
+ # user.update!(if: { age: 20 }) do |t|
584
599
  # t.add(age: 1)
585
600
  # end
586
601
  #
@@ -593,15 +608,24 @@ module Dynamoid
593
608
  # fail.
594
609
  #
595
610
  # @param conditions [Hash] Conditions on model attributes to make a conditional update (optional)
611
+ # @return [Dynamoid::Document] self
596
612
  def update!(conditions = {})
597
613
  run_callbacks(:update) do
598
- options = range_key ? { range_key: Dumping.dump_field(read_attribute(range_key), self.class.attributes[range_key]) } : {}
614
+ options = {}
615
+ if range_key
616
+ value = read_attribute(range_key)
617
+ attribute_options = self.class.attributes[range_key]
618
+ options[:range_key] = Dumping.dump_field(value, attribute_options)
619
+ end
599
620
 
600
621
  begin
601
- new_attrs = Dynamoid.adapter.update_item(self.class.table_name, hash_key, options.merge(conditions: conditions)) do |t|
622
+ table_name = self.class.table_name
623
+ update_item_options = options.merge(conditions: conditions)
624
+
625
+ new_attrs = Dynamoid.adapter.update_item(table_name, hash_key, update_item_options) do |t|
602
626
  t.add(lock_version: 1) if self.class.attributes[:lock_version]
603
627
 
604
- if Dynamoid::Config.timestamps
628
+ if self.class.timestamps_enabled?
605
629
  time_now = DateTime.now.in_time_zone(Time.zone)
606
630
  time_now_dumped = Dumping.dump_field(time_now, self.class.attributes[:updated_at])
607
631
  t.set(updated_at: time_now_dumped)
@@ -614,6 +638,8 @@ module Dynamoid
614
638
  raise Dynamoid::Errors::StaleObjectError.new(self, 'update')
615
639
  end
616
640
  end
641
+
642
+ self
617
643
  end
618
644
 
619
645
  # Update a model.
@@ -639,6 +665,19 @@ module Dynamoid
639
665
  # t.delete(hobbies: ['skying'])
640
666
  # end
641
667
  #
668
+ # If it's applied to a scalar attribute then the item's attribute is
669
+ # removed at all:
670
+ #
671
+ # user.update do |t|
672
+ # t.delete(age: nil)
673
+ # end
674
+ #
675
+ # or even without useless value at all:
676
+ #
677
+ # user.update do |t|
678
+ # t.delete(:age)
679
+ # end
680
+ #
642
681
  # Operation +set+ just changes an attribute value:
643
682
  #
644
683
  # user.update do |t|
@@ -664,6 +703,7 @@ module Dynamoid
664
703
  # fail.
665
704
  #
666
705
  # @param conditions [Hash] Conditions on model attributes to make a conditional update (optional)
706
+ # @return [true|false] - whether conditions are met and updating is successful
667
707
  def update(conditions = {}, &block)
668
708
  update!(conditions, &block)
669
709
  true
@@ -748,9 +788,9 @@ module Dynamoid
748
788
  # Supports optimistic locking with the +lock_version+ attribute and doesn't
749
789
  # delete a model if it's already changed.
750
790
  #
751
- # Returns +true+ if deleted successfully and +false+ otherwise.
791
+ # Returns +self+ if deleted successfully and +false+ otherwise.
752
792
  #
753
- # @return [true|false] whether deleted successfully
793
+ # @return [Dynamoid::Document|false] whether deleted successfully
754
794
  # @since 0.2.0
755
795
  def destroy
756
796
  ret = run_callbacks(:destroy) do
@@ -783,6 +823,7 @@ module Dynamoid
783
823
  # Raises +Dynamoid::Errors::StaleObjectError+ exception if cannot delete a
784
824
  # model.
785
825
  #
826
+ # @return [Dynamoid::Document] self
786
827
  # @since 0.2.0
787
828
  def delete
788
829
  options = range_key ? { range_key: Dumping.dump_field(read_attribute(range_key), self.class.attributes[range_key]) } : {}
@@ -806,6 +847,8 @@ module Dynamoid
806
847
  self.class.associations.each do |name, options|
807
848
  send(name).disassociate_source
808
849
  end
850
+
851
+ self
809
852
  rescue Dynamoid::Errors::ConditionalCheckFailedException
810
853
  raise Dynamoid::Errors::StaleObjectError.new(self, 'delete')
811
854
  end
@@ -236,9 +236,27 @@ module Dynamoid
236
236
  end
237
237
 
238
238
  class SerializedUndumper < Base
239
+ # We must use YAML.safe_load in Ruby 3.1 to handle serialized Set class
240
+ minimum_ruby_version = ->(version) { Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(version) }
241
+ # Once we drop support for Rubies older than 2.6 we can remove this conditional (with major version bump)!
242
+ # YAML_SAFE_LOAD = minimum_ruby_version.call("2.6")
243
+ # But we don't want to change behavior for Ruby <= 3.0 that has been using the gem, without a major version bump
244
+ YAML_SAFE_LOAD = minimum_ruby_version.call('3.1')
245
+
239
246
  def process(value)
240
247
  if @options[:serializer]
241
248
  @options[:serializer].load(value)
249
+ elsif YAML_SAFE_LOAD
250
+ # The classes listed in permitted classes are added to the default set of "safe loadable" classes.
251
+ # TrueClass
252
+ # FalseClass
253
+ # NilClass
254
+ # Integer
255
+ # Float
256
+ # String
257
+ # Array
258
+ # Hash
259
+ YAML.safe_load(value, permitted_classes: [Symbol, Set, Date, Time, DateTime])
242
260
  else
243
261
  YAML.load(value)
244
262
  end
@@ -38,6 +38,12 @@ module Dynamoid
38
38
  self
39
39
  end
40
40
 
41
+ def update_attribute(attribute, value)
42
+ write_attribute(attribute, value)
43
+ save(validate: false)
44
+ self
45
+ end
46
+
41
47
  module ClassMethods
42
48
  # Override validates_presence_of to handle false values as present.
43
49
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynamoid
4
- VERSION = '3.7.0'
4
+ VERSION = '3.8.0'
5
5
  end
data/lib/dynamoid.rb CHANGED
@@ -4,6 +4,7 @@ require 'aws-sdk-dynamodb'
4
4
  require 'delegate'
5
5
  require 'time'
6
6
  require 'securerandom'
7
+ require 'set'
7
8
  require 'active_support'
8
9
  require 'active_support/core_ext'
9
10
  require 'active_support/json'