dynamoid 3.12.1 → 3.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3e330233bdd01c0bb962fb7253b48629fe9f0aeb1ecdcb6cfe7d3272ddddbe0
4
- data.tar.gz: 7f6ee27e8d6dedb5fe21bce21a6c00397f42d85dfa40b8d30dae4e13a8294d15
3
+ metadata.gz: b115b0eb8a9244d3482f3b748edf42272f6d9ffa94db20c5c07c7d08a6a3cf6a
4
+ data.tar.gz: 60821b56861e74149f3751f0c58bbc58e6805a9b4db0a4b863527aad99748099
5
5
  SHA512:
6
- metadata.gz: f84a868794dd6b812ccd6936eaa16c91a8d0f87b2ae959b89a8b01802537490168449bfa83f62d92f8643a6c47043cefeb9168da3301ca4f005400bc1086406f
7
- data.tar.gz: 528587c73879f49d25e660974fa5832464eed5773da06535a76bfe3c9c96311a1877eb801e68362c94eadc479cf65a8df053a0f47574c78fbf5919859cb88e66
6
+ metadata.gz: 1eef7ad0569e01d06b8f52a273acff4621a23419bc7f83830dfb37a44149149ec41b010cfa96c4e81d51043b4c7154c7996c72d7e50b599b4ea969ff6bb7575f
7
+ data.tar.gz: ee7561403e8f0bfd2c0e29e2212c0cec903e264c83adc9d238d565054bc98f111d85b3e70e64e838d6c35a434d8b7ef3025eba347ccfee53d249a3834ce53dc0
data/CHANGELOG.md CHANGED
@@ -11,7 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
  ### Changed
12
12
  ### Removed
13
13
 
14
- ## 3.12.0
14
+ ## 3.13.0
15
+
16
+ ### Fixed
17
+ * [#944](https://github.com/Dynamoid/dynamoid/pull/944) Fix `#delete` and `#destroy` methods and set `#destroyed?` properly when operations fail
18
+ * [#987](https://github.com/Dynamoid/dynamoid/pull/987) Fix checking that a primary key is given in transactional methods `#save` and `#destroy`
19
+ ### Added
20
+ * [#941](https://github.com/Dynamoid/dynamoid/pull/941) Support table ARN and add option `:arn` for the `table` method to specify a table belonged to specific AWS account
21
+ * [#943](https://github.com/Dynamoid/dynamoid/pull/943) Implement `delete` class method
22
+ * [#945](https://github.com/Dynamoid/dynamoid/pull/945) Implement `#update_attribute!` method
23
+ * [#947](https://github.com/Dynamoid/dynamoid/pull/947) Allow skipping default model fields generation and add option `:skip_generating_fields` for the `table` method to specify field names
24
+ * [#988](https://github.com/Dynamoid/dynamoid/pull/988) Add Ruby 4.0 and Rails 8.1 in CI
25
+ ### Changed
26
+ ### Removed
27
+
28
+ ## 3.12.0 / 2025-08-23
15
29
 
16
30
  ### Fixed
17
31
  * [#849](https://github.com/Dynamoid/dynamoid/pull/849) Fixed saving a field of custom type when it implements both `.dynamoid_dump()` and `#dynamoid_dump` method and use the former one.
data/README.md CHANGED
@@ -132,8 +132,8 @@ end
132
132
  Dynamoid supports Ruby >= 2.3 and Rails >= 4.2.
133
133
 
134
134
  Its compatibility is tested against following Ruby versions: 2.3, 2.4,
135
- 2.5, 2.6, 2.7, 3.0, 3.1, 3.2, 3.3, and 3.4, JRuby 9.4.x and against Rails versions: 4.2, 5.0, 5.1,
136
- 5.2, 6.0, 6.1, 7.0, 7.1, 7.2, and 8.0.
135
+ 2.5, 2.6, 2.7, 3.0, 3.1, 3.2, 3.3, 3.4, and 4.0, JRuby 9.4.x and against Rails versions: 4.2, 5.0, 5.1,
136
+ 5.2, 6.0, 6.1, 7.0, 7.1, 7.2, 8.0, and 8.1.
137
137
 
138
138
  ## Setup
139
139
 
@@ -1131,7 +1131,7 @@ The following actions are supported:
1131
1131
 
1132
1132
  * `#create`/`#create!` - add a new model if it does not already exist
1133
1133
  * `#save`/`#save!` - create or update model
1134
- * `#update_attributes`/`#update_attributes!` - modifies one or more attributes from an existig
1134
+ * `#update_attributes`/`#update_attributes!` - modifies one or more attributes from an existing
1135
1135
  model
1136
1136
  * `#delete` - remove an model without callbacks nor validations
1137
1137
  * `#destroy`/`#destroy!` - remove an model
data/SECURITY.md CHANGED
@@ -4,14 +4,14 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|-----------|
7
- | 3.7.x | ✅ |
8
- | <= 3.6 | ❌ |
7
+ | 3.12.x | ✅ |
8
+ | < 3.12 | ❌ |
9
9
  | 2.x | ❌ |
10
10
  | 1.x | ❌ |
11
11
  | 0.x | ❌ |
12
12
 
13
- ## Reporting a Vulnerability
13
+ ## Security contact information
14
14
 
15
- Peter Boling is responsible for the security maintenance of this gem. Please find a way
16
- to [contact him directly](https://railsbling.com/contact) to report the issue. Include as much relevant information as
17
- possible.
15
+ To report a security vulnerability, please use the
16
+ [Tidelift security contact](https://tidelift.com/security).
17
+ Tidelift will coordinate the fix and disclosure.
@@ -63,9 +63,11 @@ module Dynamoid
63
63
  ids.map { |hk, rk| { table.hash_key => hk, table.range_key => rk } }
64
64
  end
65
65
 
66
+ table_name = table.local? ? table.name : table.arn
67
+
66
68
  {
67
69
  request_items: {
68
- table.name => {
70
+ table_name => {
69
71
  keys: keys,
70
72
  consistent_read: options[:consistent_read]
71
73
  }
@@ -58,7 +58,7 @@ module Dynamoid
58
58
  # delete explicitly attributes if assigned nil value and configured
59
59
  # to not store nil values
60
60
  values_to_update = values_sanitized.reject { |_, v| v.nil? }
61
- values_to_delete = values_sanitized.select { |_, v| v.nil? }
61
+ values_to_delete = values_sanitized.select { |_, v| v.nil? } # rubocop:disable Style/PartitionInsteadOfDoubleSelect
62
62
 
63
63
  @updates.merge!(values_to_update)
64
64
  @deletions.merge!(values_to_delete)
@@ -64,6 +64,9 @@ module Dynamoid
64
64
  name_placeholders = {}
65
65
  value_placeholders = {}
66
66
 
67
+ # use table arn if it's configured for a model
68
+ table_name = table.local? ? table.name : table.arn
69
+
67
70
  # Deal with various limits and batching
68
71
  batch_size = options[:batch_size]
69
72
  limit = [record_limit, scan_limit, batch_size].compact.min
@@ -93,7 +96,7 @@ module Dynamoid
93
96
  :exclusive_start_key
94
97
  ).compact
95
98
 
96
- request[:table_name] = table.name
99
+ request[:table_name] = table_name
97
100
  request[:limit] = limit if limit
98
101
  request[:key_condition_expression] = key_condition_expression if key_condition_expression.present?
99
102
  request[:filter_expression] = filter_expression if filter_expression.present?
@@ -57,6 +57,9 @@ module Dynamoid
57
57
  name_placeholders = {}
58
58
  value_placeholders = {}
59
59
 
60
+ # use table arn if it's configured for a model
61
+ table_name = table.local? ? table.name : table.arn
62
+
60
63
  # Deal with various limits and batching
61
64
  batch_size = options[:batch_size]
62
65
  limit = [record_limit, scan_limit, batch_size].compact.min
@@ -79,7 +82,7 @@ module Dynamoid
79
82
  :index_name
80
83
  ).compact
81
84
 
82
- request[:table_name] = table.name
85
+ request[:table_name] = table_name
83
86
  request[:limit] = limit if limit
84
87
  request[:filter_expression] = filter_expression if filter_expression.present?
85
88
  request[:expression_attribute_values] = value_placeholders if value_placeholders.present?
@@ -14,6 +14,7 @@ module Dynamoid
14
14
  #
15
15
  def initialize(schema)
16
16
  @schema = schema[:table]
17
+ @local = false
17
18
  end
18
19
 
19
20
  def range_key
@@ -47,6 +48,18 @@ module Dynamoid
47
48
  def name
48
49
  schema[:table_name]
49
50
  end
51
+
52
+ def arn
53
+ schema[:table_arn]
54
+ end
55
+
56
+ def local!
57
+ @local = true
58
+ end
59
+
60
+ def local?
61
+ @local
62
+ end
50
63
  end
51
64
  end
52
65
  end
@@ -321,6 +321,22 @@ module Dynamoid
321
321
  false
322
322
  end
323
323
 
324
+ #
325
+ # New, semi-arbitrary API to get data on the table
326
+ #
327
+ def describe_table(table_name, reload: false)
328
+ (!reload && table_cache[table_name]) || begin
329
+ response = client.describe_table(table_name: table_name)
330
+ table = Table.new(response.data)
331
+
332
+ if table.name == table_name.to_s
333
+ table.local!
334
+ end
335
+
336
+ table_cache[table_name] = table
337
+ end
338
+ end
339
+
324
340
  def update_time_to_live(table_name, attribute)
325
341
  request = {
326
342
  table_name: table_name,
@@ -654,15 +670,6 @@ module Dynamoid
654
670
  expected
655
671
  end
656
672
 
657
- #
658
- # New, semi-arbitrary API to get data on the table
659
- #
660
- def describe_table(table_name, reload: false)
661
- (!reload && table_cache[table_name]) || begin
662
- table_cache[table_name] = Table.new(client.describe_table(table_name: table_name).data)
663
- end
664
- end
665
-
666
673
  #
667
674
  # Converts a hash returned by get_item, scan, etc. into a key-value hash
668
675
  #
@@ -77,7 +77,7 @@ module Dynamoid
77
77
  end
78
78
 
79
79
  def warn_if_method_exists(method)
80
- if @source.instance_methods.include?(method.to_sym)
80
+ if @source.method_defined?(method.to_sym)
81
81
  Dynamoid.logger.warn("Method #{method} generated for the field #{@name} overrides already existing method")
82
82
  end
83
83
  end
@@ -134,6 +134,11 @@ module Dynamoid
134
134
  # @param name [Symbol] name of the field
135
135
  # @param type [Symbol] type of the field (optional)
136
136
  # @param options [Hash] any additional options for the field type (optional)
137
+ # @option options [Symbol] :of <description>
138
+ # @option options [Symbol] :store_as_string <description>
139
+ # @option options [Symbol] :store_as_native_boolean <description>
140
+ # @option options [Symbol] :default <description>
141
+ # @option options [Symbol] :alias <description>
137
142
  #
138
143
  # @since 0.2.0
139
144
  def field(name, type = :string, options = {})
@@ -208,9 +213,11 @@ module Dynamoid
208
213
  #
209
214
  # @param options [Hash] options to override default table settings
210
215
  # @option options [Symbol] :name name of a table
216
+ # @option options [Symbol] :arn table ARN; it allows referring tables in another AWS accounts; has higher priority than the +name+ option
211
217
  # @option options [Symbol] :key name of a hash key attribute
212
218
  # @option options [Symbol] :key_type type of a hash key attribute
213
219
  # @option options [Symbol] :inheritance_field name of an attribute used for STI
220
+ # @option options [Array<Symbol>] :skip_generating_fields don't generate implicitly methods with given names, e.g. +:id+, +:created_at+, +:updated_at+
214
221
  # @option options [Symbol] :capacity_mode table billing mode - either +provisioned+ or +on_demand+
215
222
  # @option options [Integer] :write_capacity table write capacity units
216
223
  # @option options [Integer] :read_capacity table read capacity units
@@ -220,11 +227,18 @@ module Dynamoid
220
227
  # @since 0.4.0
221
228
  def table(options)
222
229
  self.options = options
230
+
231
+ id_already_declared = true
232
+ timestamps_already_declared = Dynamoid::Config.timestamps
233
+
223
234
  # a default 'id' column is created when Dynamoid::Document is included
224
235
  unless attributes.key? hash_key
225
236
  remove_field :id
237
+ id_already_declared = false
238
+
226
239
  key_type = options[:key_type] || :string
227
240
  field(hash_key, key_type)
241
+ id_already_declared = hash_key == :id
228
242
  end
229
243
 
230
244
  # The created_at/updated_at fields are declared in the `included` callback first.
@@ -233,14 +247,30 @@ module Dynamoid
233
247
  # So we need to make decision again and declare the fields or rollback thier declaration.
234
248
  #
235
249
  # Do not replace with `#timestamps_enabled?`.
236
- if options[:timestamps] && !Dynamoid::Config.timestamps
250
+ if options[:timestamps] && !timestamps_already_declared
237
251
  # The fields weren't declared in `included` callback because they are disabled globaly
238
252
  field :created_at, :datetime
239
253
  field :updated_at, :datetime
240
- elsif options[:timestamps] == false && Dynamoid::Config.timestamps
254
+
255
+ timestamps_already_declared = true
256
+ elsif options[:timestamps] == false && timestamps_already_declared
241
257
  # The fields were declared in `included` callback but they are disabled for a table
242
258
  remove_field :created_at
243
259
  remove_field :updated_at
260
+
261
+ timestamps_already_declared = false
262
+ end
263
+
264
+ # handle :skip_generating_fields option
265
+ skip_generating_fields = options[:skip_generating_fields] || []
266
+ if id_already_declared && skip_generating_fields.include?(:id)
267
+ remove_field :id
268
+ end
269
+ if timestamps_already_declared && skip_generating_fields.include?(:created_at)
270
+ remove_field :created_at
271
+ end
272
+ if timestamps_already_declared && skip_generating_fields.include?(:updated_at)
273
+ remove_field :updated_at
244
274
  end
245
275
  end
246
276
 
@@ -133,12 +133,14 @@ module Dynamoid
133
133
  end
134
134
 
135
135
  read_options = options.slice(:consistent_read)
136
+ table = Dynamoid.adapter.describe_table(table_name)
136
137
 
137
138
  items = if Dynamoid.config.backoff
138
139
  items = []
139
140
  backoff = nil
140
141
  Dynamoid.adapter.read(table_name, ids, read_options) do |hash, has_unprocessed_items|
141
- items += hash[table_name]
142
+ # Cannot use #table_name because it may contain a table ARN, but response contains always a table name
143
+ items += hash[table.name]
142
144
 
143
145
  if has_unprocessed_items
144
146
  backoff ||= Dynamoid.config.build_backoff
@@ -150,7 +152,9 @@ module Dynamoid
150
152
  items
151
153
  else
152
154
  items = Dynamoid.adapter.read(table_name, ids, read_options)
153
- items ? items[table_name] : []
155
+
156
+ # Cannot use #table_name because it may contain a table ARN, but response contains always a table name
157
+ items ? items[table.name] : []
154
158
  end
155
159
 
156
160
  if items.size == ids.size || !options[:raise_error]
@@ -28,9 +28,16 @@ module Dynamoid
28
28
 
29
29
  module ClassMethods
30
30
  def table_name
31
- table_base_name = options[:name] || base_class.name.split('::').last.downcase.pluralize
31
+ return @table_name if @table_name
32
32
 
33
- @table_name ||= [Dynamoid::Config.namespace.to_s, table_base_name].reject(&:empty?).join('_')
33
+ if options[:arn]
34
+ @table_name = options[:arn]
35
+ return @table_name
36
+ end
37
+
38
+ base_name = options[:name] || base_class.name.split('::').last.downcase.pluralize
39
+ namespace = Dynamoid::Config.namespace.to_s
40
+ @table_name = [namespace, base_name].reject(&:empty?).join('_')
34
41
  end
35
42
 
36
43
  # Create a table.
@@ -209,6 +216,9 @@ module Dynamoid
209
216
  # Raises an exception +Dynamoid::Errors::DocumentNotValid+ if validation
210
217
  # failed.
211
218
  #
219
+ # If any of the +before_*+ callbacks throws +:abort+ the creation is
220
+ # cancelled and +create!+ raises +Dynamoid::Errors::RecordNotSaved+.
221
+ #
212
222
  # Accepts both Hash and Array of Hashes and can create several
213
223
  # models.
214
224
  #
@@ -448,6 +458,88 @@ module Dynamoid
448
458
  Inc.call(self, hash_key_value, range_key_value, counters)
449
459
  self
450
460
  end
461
+
462
+ # Delete a model by a given primary key.
463
+ #
464
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
465
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
466
+ # required but has value +nil+ or is missing.
467
+ #
468
+ # @param ids [String|Array] primary key or an array of primary keys
469
+ # @return nil
470
+ #
471
+ # @example Delete a model by given partition key:
472
+ # User.delete(user_id)
473
+ #
474
+ # @example Delete a model by given partition and sort keys:
475
+ # User.delete(user_id, sort_key)
476
+ #
477
+ # @example Delete multiple models by given partition keys:
478
+ # User.delete([id1, id2, id3])
479
+ #
480
+ # @example Delete multiple models by given partition and sort keys:
481
+ # User.delete([[id1, sk1], [id2, sk2], [id3, sk3]])
482
+ def delete(*ids)
483
+ if ids.empty?
484
+ raise Dynamoid::Errors::MissingHashKey
485
+ end
486
+
487
+ if ids[0].is_a?(Array)
488
+ # given multiple keys
489
+ # Model.delete([id1, id2, id3])
490
+ keys = ids[0] # ignore other arguments
491
+
492
+ ids = []
493
+ if self.range_key
494
+ # compound primary key
495
+ # expect [hash key, range key] pairs
496
+
497
+ range_key = []
498
+
499
+ # assume all elements are pairs, that's arrays
500
+ keys.each do |pk, sk|
501
+ raise Dynamoid::Errors::MissingHashKey if pk.nil?
502
+ raise Dynamoid::Errors::MissingRangeKey if self.range_key && sk.nil?
503
+
504
+ partition_key_dumped = cast_and_dump(hash_key, pk)
505
+ sort_key_dumped = cast_and_dump(self.range_key, sk)
506
+
507
+ ids << partition_key_dumped
508
+ range_key << sort_key_dumped
509
+ end
510
+ else
511
+ # simple primary key
512
+
513
+ range_key = nil
514
+
515
+ keys.each do |pk|
516
+ raise Dynamoid::Errors::MissingHashKey if pk.nil?
517
+
518
+ partition_key_dumped = cast_and_dump(hash_key, pk)
519
+ ids << partition_key_dumped
520
+ end
521
+ end
522
+
523
+ options = range_key ? { range_key: range_key } : {}
524
+ Dynamoid.adapter.delete(table_name, ids, options)
525
+ else
526
+ # given single primary key:
527
+ # Model.delete(partition_key)
528
+ # Model.delete(partition_key, sort_key)
529
+
530
+ partition_key, sort_key = ids
531
+
532
+ raise Dynamoid::Errors::MissingHashKey if partition_key.nil?
533
+ raise Dynamoid::Errors::MissingRangeKey if range_key? && sort_key.nil?
534
+
535
+ options = sort_key ? { range_key: cast_and_dump(self.range_key, sort_key) } : {}
536
+ partition_key_dumped = cast_and_dump(hash_key, partition_key)
537
+
538
+ Dynamoid.adapter.delete(table_name, partition_key_dumped, options)
539
+ end
540
+
541
+ nil
542
+ end
451
543
  end
452
544
 
453
545
  # Update document timestamps.
@@ -593,6 +685,9 @@ module Dynamoid
593
685
  # user = User.new(age: -1)
594
686
  # user.save!(validate: false) # => user
595
687
  #
688
+ # If any of the +before_*+ callbacks throws +:abort+ the saving is
689
+ # cancelled and +save!+ raises +Dynamoid::Errors::RecordNotSaved+.
690
+ #
596
691
  # +save!+ by default sets timestamps attributes - +created_at+ and
597
692
  # +updated_at+ when creates new model and updates +updated_at+ attribute
598
693
  # when updates already existing one.
@@ -688,6 +783,10 @@ module Dynamoid
688
783
  # Raises a +Dynamoid::Errors::DocumentNotValid+ exception if some vaidation
689
784
  # fails.
690
785
  #
786
+ # If any of the +before_*+ callbacks throws +:abort+ the updating is
787
+ # cancelled and +update_attributes!+ raises
788
+ # +Dynamoid::Errors::RecordNotSaved+.
789
+ #
691
790
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
692
791
  # attributes is not on the model
693
792
  #
@@ -703,8 +802,6 @@ module Dynamoid
703
802
 
704
803
  # Update a single attribute, saving the object afterwards.
705
804
  #
706
- # Returns +true+ if saving is successful and +false+ otherwise.
707
- #
708
805
  # user.update_attribute(:last_name, 'Tylor')
709
806
  #
710
807
  # Validation is skipped.
@@ -721,6 +818,28 @@ module Dynamoid
721
818
  # final implementation is in the Dynamoid::Validation module
722
819
  end
723
820
 
821
+ # Update a single attribute, saving the object afterwards.
822
+ #
823
+ # user.update_attribute!(:last_name, 'Tylor')
824
+ #
825
+ # Validation is skipped.
826
+ #
827
+ # If any of the +before_*+ callbacks throws +:abort+ the updating is
828
+ # cancelled and +update_attribute!+ raises
829
+ # +Dynamoid::Errors::RecordNotSaved+.
830
+ #
831
+ # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
832
+ # attributes is not on the model
833
+ #
834
+ # @param attribute [Symbol] attribute name to update
835
+ # @param value [Object] the value to assign it
836
+ # @return [Dynamoid::Document] self
837
+ #
838
+ # @since 0.2.0
839
+ def update_attribute!(attribute, value)
840
+ # final implementation is in the Dynamoid::Validation module
841
+ end
842
+
724
843
  # Update a model.
725
844
  #
726
845
  # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
@@ -1017,13 +1136,12 @@ module Dynamoid
1017
1136
  # @return [Dynamoid::Document|false] whether deleted successfully
1018
1137
  # @since 0.2.0
1019
1138
  def destroy
1020
- ret = run_callbacks(:destroy) do
1139
+ run_callbacks(:destroy) do
1021
1140
  delete
1141
+ @destroyed = true
1022
1142
  end
1023
1143
 
1024
- @destroyed = true
1025
-
1026
- ret == false ? false : self
1144
+ @destroyed ? self : false
1027
1145
  end
1028
1146
 
1029
1147
  # Delete a model.
@@ -1086,6 +1204,7 @@ module Dynamoid
1086
1204
 
1087
1205
  self
1088
1206
  rescue Dynamoid::Errors::ConditionalCheckFailedException
1207
+ @destroyed = false
1089
1208
  raise Dynamoid::Errors::StaleObjectError.new(self, 'delete')
1090
1209
  end
1091
1210
  end
@@ -42,6 +42,18 @@ module Dynamoid
42
42
  def action_request
43
43
  raise 'Not implemented'
44
44
  end
45
+
46
+ # copied from aws_sdk_v3
47
+ def sanitize_item(attributes)
48
+ config_value = Dynamoid.config.store_attribute_with_nil_value
49
+ store_attribute_with_nil_value = config_value.nil? ? false : !!config_value
50
+
51
+ attributes.reject do |_, v|
52
+ !store_attribute_with_nil_value && v.nil?
53
+ end.transform_values do |v|
54
+ v.is_a?(Hash) ? v.stringify_keys : v
55
+ end
56
+ end
45
57
  end
46
58
  end
47
59
  end
@@ -15,10 +15,10 @@ module Dynamoid
15
15
  end
16
16
 
17
17
  def on_registration
18
- validate_model!
19
-
20
18
  @aborted = true
21
19
  @model.run_callbacks(:destroy) do
20
+ validate_primary_key!
21
+
22
22
  @aborted = false
23
23
  true
24
24
  end
@@ -70,7 +70,7 @@ module Dynamoid
70
70
 
71
71
  private
72
72
 
73
- def validate_model!
73
+ def validate_primary_key!
74
74
  raise Dynamoid::Errors::MissingHashKey if @model.hash_key.nil?
75
75
  raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @model.range_value.nil?
76
76
  end
@@ -20,7 +20,12 @@ module Dynamoid
20
20
 
21
21
  def set(attributes)
22
22
  validate_attribute_names!(attributes.keys)
23
- @attributes_to_set.merge!(attributes)
23
+ if Dynamoid.config.store_attribute_with_nil_value
24
+ @attributes_to_set.merge!(attributes)
25
+ else
26
+ @attributes_to_set.merge!(attributes.reject { |_, v| v.nil? })
27
+ @attributes_to_remove += attributes.select { |_, v| v.nil? }.keys
28
+ end
24
29
  end
25
30
 
26
31
  # adds to array of fields for use in REMOVE update expression
@@ -18,8 +18,6 @@ module Dynamoid
18
18
  end
19
19
 
20
20
  def on_registration
21
- validate_model!
22
-
23
21
  if @options[:validate] != false && !(@valid = @model.valid?)
24
22
  if @options[:raise_error]
25
23
  raise Dynamoid::Errors::DocumentNotValid, @model
@@ -35,6 +33,8 @@ module Dynamoid
35
33
  @model.run_callbacks(:save) do
36
34
  @model.run_callbacks(callback_name) do
37
35
  @model.run_callbacks(:validate) do
36
+ validate_primary_key!
37
+
38
38
  @aborted = false
39
39
  true
40
40
  end
@@ -88,7 +88,7 @@ module Dynamoid
88
88
 
89
89
  private
90
90
 
91
- def validate_model!
91
+ def validate_primary_key!
92
92
  raise Dynamoid::Errors::MissingHashKey if !@was_new_record && @model.hash_key.nil?
93
93
  raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @model.range_value.nil?
94
94
  end
@@ -97,6 +97,7 @@ module Dynamoid
97
97
  touch_model_timestamps(skip_created_at: false)
98
98
 
99
99
  attributes_dumped = Dynamoid::Dumping.dump_attributes(@model.attributes, @model_class.attributes)
100
+ attributes_dumped = sanitize_item(attributes_dumped)
100
101
 
101
102
  # require primary key not to exist yet
102
103
  condition = "attribute_not_exists(#{@model_class.hash_key})"
@@ -126,7 +127,8 @@ module Dynamoid
126
127
 
127
128
  # Build UpdateExpression and keep names and values placeholders mapping
128
129
  # in ExpressionAttributeNames and ExpressionAttributeValues.
129
- update_expression_statements = []
130
+ set_expression_statements = []
131
+ remove_expression_statements = []
130
132
  expression_attribute_names = {}
131
133
  expression_attribute_values = {}
132
134
 
@@ -134,12 +136,18 @@ module Dynamoid
134
136
  name_placeholder = "#_n#{i}"
135
137
  value_placeholder = ":_s#{i}"
136
138
 
137
- update_expression_statements << "#{name_placeholder} = #{value_placeholder}"
139
+ if value || Dynamoid.config.store_attribute_with_nil_value
140
+ set_expression_statements << "#{name_placeholder} = #{value_placeholder}"
141
+ expression_attribute_values[value_placeholder] = value
142
+ else
143
+ remove_expression_statements << name_placeholder
144
+ end
138
145
  expression_attribute_names[name_placeholder] = name
139
- expression_attribute_values[value_placeholder] = value
140
146
  end
141
147
 
142
- update_expression = "SET #{update_expression_statements.join(', ')}"
148
+ update_expression = ''
149
+ update_expression += "SET #{set_expression_statements.join(', ')}" if set_expression_statements.any?
150
+ update_expression += " REMOVE #{remove_expression_statements.join(', ')}" if remove_expression_statements.any?
143
151
 
144
152
  {
145
153
  update: {
@@ -54,7 +54,15 @@ module Dynamoid
54
54
  changes = add_timestamps(changes, skip_created_at: true)
55
55
  changes_dumped = Dynamoid::Dumping.dump_attributes(changes, @model_class.attributes)
56
56
 
57
- builder.set_attributes(changes_dumped)
57
+ if Dynamoid.config.store_attribute_with_nil_value
58
+ builder.set_attributes(changes_dumped)
59
+ else
60
+ nil_attributes = changes_dumped.select { |_, v| v.nil? }
61
+ non_nil_attributes = changes_dumped.reject { |_, v| v.nil? } # rubocop:disable Style/PartitionInsteadOfDoubleSelect
62
+
63
+ builder.remove_attributes(nil_attributes.keys)
64
+ builder.set_attributes(non_nil_attributes)
65
+ end
58
66
 
59
67
  # given a block
60
68
  if @item_updater
@@ -54,7 +54,8 @@ module Dynamoid
54
54
 
55
55
  # Build UpdateExpression and keep names and values placeholders mapping
56
56
  # in ExpressionAttributeNames and ExpressionAttributeValues.
57
- update_expression_statements = []
57
+ set_expression_statements = []
58
+ remove_expression_statements = []
58
59
  expression_attribute_names = {}
59
60
  expression_attribute_values = {}
60
61
 
@@ -62,12 +63,18 @@ module Dynamoid
62
63
  name_placeholder = "#_n#{i}"
63
64
  value_placeholder = ":_s#{i}"
64
65
 
65
- update_expression_statements << "#{name_placeholder} = #{value_placeholder}"
66
+ if value || Dynamoid.config.store_attribute_with_nil_value
67
+ set_expression_statements << "#{name_placeholder} = #{value_placeholder}"
68
+ expression_attribute_values[value_placeholder] = value
69
+ else
70
+ remove_expression_statements << name_placeholder
71
+ end
66
72
  expression_attribute_names[name_placeholder] = name
67
- expression_attribute_values[value_placeholder] = value
68
73
  end
69
74
 
70
- update_expression = "SET #{update_expression_statements.join(', ')}"
75
+ update_expression = ''
76
+ update_expression += "SET #{set_expression_statements.join(', ')}" if set_expression_statements.any?
77
+ update_expression += " REMOVE #{remove_expression_statements.join(', ')}" if remove_expression_statements.any?
71
78
 
72
79
  {
73
80
  update: {
@@ -47,6 +47,14 @@ module Dynamoid
47
47
  self
48
48
  end
49
49
 
50
+ def update_attribute!(attribute, value)
51
+ write_attribute(attribute, value)
52
+ save!(validate: false)
53
+ self
54
+ rescue Dynamoid::Errors::StaleObjectError
55
+ self
56
+ end
57
+
50
58
  module ClassMethods
51
59
  # Override validates_presence_of to handle false values as present.
52
60
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynamoid
4
- VERSION = '3.12.1'
4
+ VERSION = '3.13.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dynamoid
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.1
4
+ version: 3.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Symonds
@@ -18,10 +18,9 @@ authors:
18
18
  - Brian Glusman
19
19
  - Peter Boling
20
20
  - Andrew Konchin
21
- autorequire:
22
21
  bindir: bin
23
22
  cert_chain: []
24
- date: 2025-08-23 00:00:00.000000000 Z
23
+ date: 1980-01-02 00:00:00.000000000 Z
25
24
  dependencies:
26
25
  - !ruby/object:Gem::Dependency
27
26
  name: activemodel
@@ -264,14 +263,13 @@ licenses:
264
263
  - MIT
265
264
  metadata:
266
265
  homepage_uri: http://github.com/Dynamoid/dynamoid
267
- source_code_uri: https://github.com/Dynamoid/dynamoid/tree/v3.12.1
268
- changelog_uri: https://github.com/Dynamoid/dynamoid/blob/v3.12.1/CHANGELOG.md
266
+ source_code_uri: https://github.com/Dynamoid/dynamoid/tree/v3.13.0
267
+ changelog_uri: https://github.com/Dynamoid/dynamoid/blob/v3.13.0/CHANGELOG.md
269
268
  bug_tracker_uri: https://github.com/Dynamoid/dynamoid/issues
270
- documentation_uri: https://www.rubydoc.info/gems/dynamoid/3.12.1
269
+ documentation_uri: https://www.rubydoc.info/gems/dynamoid/3.13.0
271
270
  funding_uri: https://opencollective.com/dynamoid
272
271
  wiki_uri: https://github.com/Dynamoid/dynamoid/wiki
273
272
  rubygems_mfa_required: 'true'
274
- post_install_message:
275
273
  rdoc_options: []
276
274
  require_paths:
277
275
  - lib
@@ -286,8 +284,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
286
284
  - !ruby/object:Gem::Version
287
285
  version: '0'
288
286
  requirements: []
289
- rubygems_version: 3.5.3
290
- signing_key:
287
+ rubygems_version: 3.6.9
291
288
  specification_version: 4
292
289
  summary: Dynamoid is an ORM for Amazon's DynamoDB
293
290
  test_files: []