dynamoid 3.7.0 → 3.8.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.
@@ -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'