• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Dynamoid / dynamoid / 10441434881

18 Aug 2024 02:32PM UTC coverage: 90.584% (+0.07%) from 90.511%
10441434881

push

github

web-flow
Merge pull request #794 from Dynamoid/add-configuration-option-to-persist-empty-strings

Add configuration option to persist empty strings as is

866 of 979 branches covered (88.46%)

Branch coverage included in aggregate %.

3184 of 3492 relevant lines covered (91.18%)

812.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.48
/lib/dynamoid/persistence.rb
1
# frozen_string_literal: true
2

3
require 'bigdecimal'
1✔
4
require 'securerandom'
1✔
5
require 'yaml'
1✔
6

7
require 'dynamoid/persistence/import'
1✔
8
require 'dynamoid/persistence/update_fields'
1✔
9
require 'dynamoid/persistence/upsert'
1✔
10
require 'dynamoid/persistence/save'
1✔
11
require 'dynamoid/persistence/inc'
1✔
12
require 'dynamoid/persistence/update_validations'
1✔
13
require 'dynamoid/persistence/item_updater_with_dumping'
1✔
14

15
# encoding: utf-8
16
module Dynamoid
1✔
17
  # Persistence is responsible for dumping objects to and marshalling objects from the data store. It tries to reserialize
18
  # values to be of the same type as when they were passed in, based on the fields in the class.
19
  module Persistence
1✔
20
    extend ActiveSupport::Concern
1✔
21

22
    attr_accessor :new_record, :destroyed
1✔
23
    alias new_record? new_record
1✔
24
    alias destroyed? destroyed
1✔
25

26
    # @private
27
    UNIX_EPOCH_DATE = Date.new(1970, 1, 1).freeze
1✔
28

29
    module ClassMethods
1✔
30
      def table_name
1✔
31
        table_base_name = options[:name] || base_class.name.split('::').last.downcase.pluralize
8,286✔
32

33
        @table_name ||= [Dynamoid::Config.namespace.to_s, table_base_name].reject(&:empty?).join('_')
8,286✔
34
      end
35

36
      # Create a table.
37
      #
38
      # Uses a configuration specified in a model class (with the +table+
39
      # method) e.g. table name, schema (hash and range keys), global and local
40
      # secondary indexes, billing mode and write/read capacity.
41
      #
42
      # For instance here
43
      #
44
      #   class User
45
      #     include Dynamoid::Document
46
      #
47
      #     table key: :uuid
48
      #     range :last_name
49
      #
50
      #     field :first_name
51
      #     field :last_name
52
      #   end
53
      #
54
      #   User.create_table
55
      #
56
      # +create_table+ method call will create a table +dynamoid_users+ with
57
      # hash key +uuid+ and range key +name+, DynamoDB default billing mode and
58
      # Dynamoid default read/write capacity units (100/20).
59
      #
60
      # All the configuration can be overridden with +options+ argument.
61
      #
62
      #   User.create_table(table_name: 'users', read_capacity: 200, write_capacity: 40)
63
      #
64
      # Dynamoid creates a table synchronously by default. DynamoDB table
65
      # creation is an asynchronous operation and a client should wait until a
66
      # table status changes to +ACTIVE+ and a table becomes available. That's
67
      # why Dynamoid is polling a table status and returns results only when a
68
      # table becomes available.
69
      #
70
      # Polling is configured with +Dynamoid::Config.sync_retry_max_times+ and
71
      # +Dynamoid::Config.sync_retry_wait_seconds+ configuration options. If
72
      # table creation takes more time than configured waiting time then
73
      # Dynamoid stops polling and returns +true+.
74
      #
75
      # In order to return back asynchronous behaviour and not to wait until a
76
      # table is created the +sync: false+ option should be specified.
77
      #
78
      #   User.create_table(sync: false)
79
      #
80
      # Subsequent method calls for the same table will be ignored.
81
      #
82
      # @param options [Hash]
83
      #
84
      # @option options [Symbol] :table_name name of the table
85
      # @option options [Symbol] :id hash key name of the table
86
      # @option options [Symbol] :hash_key_type Dynamoid type of the hash key - +:string+, +:integer+ or any other scalar type
87
      # @option options [Hash] :range_key a Hash with range key name and type in format +{ <name> => <type> }+ e.g. +{ last_name: :string }+
88
      # @option options [String] :billing_mode billing mode of a table - either +PROVISIONED+ (default) or +PAY_PER_REQUEST+ (for On-Demand Mode)
89
      # @option options [Integer] :read_capacity read capacity units for the table; does not work on existing tables and is ignored when billing mode is +PAY_PER_REQUEST+
90
      # @option options [Integer] :write_capacity write capacity units for the table; does not work on existing tables and is ignored when billing mode is +PAY_PER_REQUEST+
91
      # @option options [Hash] :local_secondary_indexes
92
      # @option options [Hash] :global_secondary_indexes
93
      # @option options [true|false] :sync specifies should the method call be synchronous and wait until a table is completely created
94
      #
95
      # @return [true|false] Whether a table created successfully
96
      # @since 0.4.0
97
      def create_table(options = {})
1✔
98
        range_key_hash = if range_key
3,421✔
99
                           { range_key => PrimaryKeyTypeMapping.dynamodb_type(attributes[range_key][:type], attributes[range_key]) }
1,127✔
100
                         end
101

102
        options = {
103
          id: hash_key,
3,417✔
104
          table_name: table_name,
105
          billing_mode: capacity_mode,
106
          write_capacity: write_capacity,
107
          read_capacity: read_capacity,
108
          range_key: range_key_hash,
109
          hash_key_type: PrimaryKeyTypeMapping.dynamodb_type(attributes[hash_key][:type], attributes[hash_key]),
110
          local_secondary_indexes: local_secondary_indexes.values,
111
          global_secondary_indexes: global_secondary_indexes.values
112
        }.merge(options)
113

114
        created_successfuly = Dynamoid.adapter.create_table(options[:table_name], options[:id], options)
3,413✔
115

116
        if created_successfuly && self.options[:expires]
3,413✔
117
          attribute = self.options[:expires][:field]
6✔
118
          Dynamoid.adapter.update_time_to_live(options[:table_name], attribute)
6✔
119
        end
120

121
        self
3,413✔
122
      end
123

124
      # Deletes the table for the model.
125
      #
126
      # Dynamoid deletes a table asynchronously and doesn't wait until a table
127
      # is deleted completely.
128
      #
129
      # Subsequent method calls for the same table will be ignored.
130
      # @return [Model class] self
131
      def delete_table
1✔
132
        Dynamoid.adapter.delete_table(table_name)
2✔
133
        self
2✔
134
      end
135

136
      # @private
137
      def from_database(attrs = {})
1✔
138
        klass = choose_right_class(attrs)
1,338✔
139
        attrs_undumped = Undumping.undump_attributes(attrs, klass.attributes)
1,338✔
140
        klass.new(attrs_undumped).tap { |r| r.new_record = false }
2,676✔
141
      end
142

143
      # Create several models at once.
144
      #
145
      #   users = User.import([{ name: 'a' }, { name: 'b' }])
146
      #
147
      # +import+ is a relatively low-level method and bypasses some
148
      # mechanisms like callbacks and validation.
149
      #
150
      # It sets timestamp fields +created_at+ and +updated_at+ if they are
151
      # blank. It sets a hash key field as well if it's blank. It expects that
152
      # the hash key field is +string+ and sets a random UUID value if the field
153
      # value is blank. All the field values are type casted to the declared
154
      # types.
155
      #
156
      # It works efficiently and uses the `BatchWriteItem` operation. In order
157
      # to cope with throttling it uses a backoff strategy if it's specified with
158
      # `Dynamoid::Config.backoff` configuration option.
159
      #
160
      # Because of the nature of DynamoDB and its limits only 25 models can be
161
      # saved at once. So multiple HTTP requests can be sent to DynamoDB.
162
      #
163
      # @param array_of_attributes [Array<Hash>]
164
      # @return [Array] Created models
165
      def import(array_of_attributes)
1✔
166
        Import.call(self, array_of_attributes)
23✔
167
      end
168

169
      # Create a model.
170
      #
171
      # Initializes a new model and immediately saves it to DynamoDB.
172
      #
173
      #   User.create(first_name: 'Mark', last_name: 'Tyler')
174
      #
175
      # Accepts both Hash and Array of Hashes and can create several models.
176
      #
177
      #   User.create([{ first_name: 'Alice' }, { first_name: 'Bob' }])
178
      #
179
      # Creates a model and pass it into a block to set other attributes.
180
      #
181
      #   User.create(first_name: 'Mark') do |u|
182
      #     u.age = 21
183
      #   end
184
      #
185
      # Validates model and runs callbacks.
186
      #
187
      # @param attrs [Hash|Array[Hash]] Attributes of the models
188
      # @param block [Proc] Block to process a document after initialization
189
      # @return [Dynamoid::Document] The created document
190
      # @since 0.2.0
191
      def create(attrs = {}, &block)
1✔
192
        if attrs.is_a?(Array)
2,314✔
193
          attrs.map { |attr| create(attr, &block) }
56✔
194
        else
2,297✔
195
          build(attrs, &block).tap(&:save)
2,297✔
196
        end
197
      end
198

199
      # Create a model.
200
      #
201
      # Initializes a new object and immediately saves it to the Dynamoid.
202
      # Raises an exception +Dynamoid::Errors::DocumentNotValid+ if validation
203
      # failed. Accepts both Hash and Array of Hashes and can create several
204
      # models.
205
      #
206
      # @param attrs [Hash|Array[Hash]] Attributes with which to create the object.
207
      # @param block [Proc] Block to process a document after initialization
208
      # @return [Dynamoid::Document] The created document
209
      # @since 0.2.0
210
      def create!(attrs = {}, &block)
1✔
211
        if attrs.is_a?(Array)
220✔
212
          attrs.map { |attr| create!(attr, &block) }
9✔
213
        else
217✔
214
          build(attrs, &block).tap(&:save!)
217✔
215
        end
216
      end
217

218
      # Update document with provided attributes.
219
      #
220
      # Instantiates document and saves changes. Runs validations and
221
      # callbacks. Don't save changes if validation fails.
222
      #
223
      #   User.update('1', age: 26)
224
      #
225
      # If range key is declared for a model it should be passed as well:
226
      #
227
      #   User.update('1', 'Tylor', age: 26)
228
      #
229
      # @param hash_key [Scalar value] hash key
230
      # @param range_key_value [Scalar value] range key (optional)
231
      # @param attrs [Hash]
232
      # @return [Dynamoid::Document] Updated document
233
      def update(hash_key, range_key_value = nil, attrs)
1✔
234
        model = find(hash_key, range_key: range_key_value, consistent_read: true)
17✔
235
        model.update_attributes(attrs)
17✔
236
        model
16✔
237
      end
238

239
      # Update document with provided attributes.
240
      #
241
      # Instantiates document and saves changes. Runs validations and
242
      # callbacks.
243
      #
244
      #   User.update!('1', age: 26)
245
      #
246
      # If range key is declared for a model it should be passed as well:
247
      #
248
      #   User.update('1', 'Tylor', age: 26)
249
      #
250
      # Raises +Dynamoid::Errors::DocumentNotValid+ exception if validation fails.
251
      #
252
      # @param hash_key [Scalar value] hash key
253
      # @param range_key_value [Scalar value] range key (optional)
254
      # @param attrs [Hash]
255
      # @return [Dynamoid::Document] Updated document
256
      def update!(hash_key, range_key_value = nil, attrs)
1✔
257
        model = find(hash_key, range_key: range_key_value, consistent_read: true)
25✔
258
        model.update_attributes!(attrs)
25✔
259
        model
23✔
260
      end
261

262
      # Update document.
263
      #
264
      # Doesn't run validations and callbacks.
265
      #
266
      #   User.update_fields('1', age: 26)
267
      #
268
      # If range key is declared for a model it should be passed as well:
269
      #
270
      #   User.update_fields('1', 'Tylor', age: 26)
271
      #
272
      # Can make a conditional update so a document will be updated only if it
273
      # meets the specified conditions. Conditions can be specified as a +Hash+
274
      # with +:if+ key:
275
      #
276
      #   User.update_fields('1', { age: 26 }, { if: { version: 1 } })
277
      #
278
      # Here +User+ model has an integer +version+ field and the document will
279
      # be updated only if the +version+ attribute currently has value 1.
280
      #
281
      # If a document with specified hash and range keys doesn't exist or
282
      # conditions were specified and failed the method call returns +nil+.
283
      #
284
      # To check if some attribute (or attributes) isn't stored in a DynamoDB
285
      # item (e.g. it wasn't set explicitly) there is another condition -
286
      # +unless_exists+:
287
      #
288
      #   user = User.create(name: 'Tylor')
289
      #   User.update_fields(user.id, { age: 18 }, { unless_exists: [:age] })
290
      #
291
      # +update_fields+ uses the +UpdateItem+ operation so it saves changes and
292
      # loads an updated document back with one HTTP request.
293
      #
294
      # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
295
      # attributes is not on the model
296
      #
297
      # @param hash_key_value [Scalar value] hash key
298
      # @param range_key_value [Scalar value] range key (optional)
299
      # @param attrs [Hash]
300
      # @param conditions [Hash] (optional)
301
      # @return [Dynamoid::Document|nil] Updated document
302
      def update_fields(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
1✔
303
        optional_params = [range_key_value, attrs, conditions].compact
37✔
304
        if optional_params.first.is_a?(Hash)
37✔
305
          range_key_value = nil
34✔
306
          attrs, conditions = optional_params[0..1]
34✔
307
        else
3✔
308
          range_key_value = optional_params.first
3✔
309
          attrs, conditions = optional_params[1..2]
3✔
310
        end
311

312
        UpdateFields.call(self,
37✔
313
                          partition_key: hash_key_value,
314
                          sort_key: range_key_value,
315
                          attributes: attrs,
316
                          conditions: conditions)
317
      end
318

319
      # Update an existing document or create a new one.
320
      #
321
      # If a document with specified hash and range keys doesn't exist it
322
      # creates a new document with specified attributes. Doesn't run
323
      # validations and callbacks.
324
      #
325
      #   User.upsert('1', age: 26)
326
      #
327
      # If range key is declared for a model it should be passed as well:
328
      #
329
      #   User.upsert('1', 'Tylor', age: 26)
330
      #
331
      # Can make a conditional update so a document will be updated only if it
332
      # meets the specified conditions. Conditions can be specified as a +Hash+
333
      # with +:if+ key:
334
      #
335
      #   User.upsert('1', { age: 26 }, { if: { version: 1 } })
336
      #
337
      # Here +User+ model has an integer +version+ field and the document will
338
      # be updated only if the +version+ attribute currently has value 1.
339
      #
340
      # To check if some attribute (or attributes) isn't stored in a DynamoDB
341
      # item (e.g. it wasn't set explicitly) there is another condition -
342
      # +unless_exists+:
343
      #
344
      #   user = User.create(name: 'Tylor')
345
      #   User.upsert(user.id, { age: 18 }, { unless_exists: [:age] })
346
      #
347
      # If conditions were specified and failed the method call returns +nil+.
348
      #
349
      # +upsert+ uses the +UpdateItem+ operation so it saves changes and loads
350
      # an updated document back with one HTTP request.
351
      #
352
      # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
353
      # attributes is not on the model
354
      #
355
      # @param hash_key_value [Scalar value] hash key
356
      # @param range_key_value [Scalar value] range key (optional)
357
      # @param attrs [Hash]
358
      # @param conditions [Hash] (optional)
359
      # @return [Dynamoid::Document|nil] Updated document
360
      def upsert(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
1✔
361
        optional_params = [range_key_value, attrs, conditions].compact
28✔
362
        if optional_params.first.is_a?(Hash)
28✔
363
          range_key_value = nil
25✔
364
          attrs, conditions = optional_params[0..1]
25✔
365
        else
3✔
366
          range_key_value = optional_params.first
3✔
367
          attrs, conditions = optional_params[1..2]
3✔
368
        end
369

370
        Upsert.call(self,
28✔
371
                    partition_key: hash_key_value,
372
                    sort_key: range_key_value,
373
                    attributes: attrs,
374
                    conditions: conditions)
375
      end
376

377
      # Increase a numeric field by specified value.
378
      #
379
      #   User.inc('1', age: 2)
380
      #
381
      # Can update several fields at once.
382
      #
383
      #   User.inc('1', age: 2, version: 1)
384
      #
385
      # If range key is declared for a model it should be passed as well:
386
      #
387
      #   User.inc('1', 'Tylor', age: 2)
388
      #
389
      # It's an atomic operation it does not interfere with other write
390
      # requests.
391
      #
392
      # Uses efficient low-level +UpdateItem+ operation and does only one HTTP
393
      # request.
394
      #
395
      # Doesn't run validations and callbacks. Doesn't update +created_at+ and
396
      # +updated_at+ as well.
397
      #
398
      # When `:touch` option is passed the timestamp columns are updating. If
399
      # attribute names are passed, they are updated along with updated_at
400
      # attribute:
401
      #
402
      #   User.inc('1', age: 2, touch: true)
403
      #   User.inc('1', age: 2, touch: :viewed_at)
404
      #   User.inc('1', age: 2, touch: [:viewed_at, :accessed_at])
405
      #
406
      # @param hash_key_value [Scalar value] hash key
407
      # @param range_key_value [Scalar value] range key (optional)
408
      # @param counters [Hash] value to increase by
409
      # @option counters [true | Symbol | Array[Symbol]] :touch to update update_at attribute and optionally the specified ones
410
      # @return [Model class] self
411
      def inc(hash_key_value, range_key_value = nil, counters)
1✔
412
        Inc.call(self, hash_key_value, range_key_value, counters)
44✔
413
        self
44✔
414
      end
415
    end
416

417
    # Update document timestamps.
418
    #
419
    # Set +updated_at+ attribute to current DateTime.
420
    #
421
    #   post.touch
422
    #
423
    # Can update other fields in addition with the same timestamp if their
424
    # names passed as arguments.
425
    #
426
    #   user.touch(:last_login_at, :viewed_at)
427
    #
428
    # Some specific value can be used to save:
429
    #
430
    #   user.touch(time: 1.hour.ago)
431
    #
432
    # No validation is performed and only +after_touch+ callback is called.
433
    #
434
    # The method must be used on a persisted object, otherwise
435
    # +Dynamoid::Errors::Error+ will be thrown.
436
    #
437
    # @param names [*Symbol] a list of attribute names to update (optional)
438
    # @param time [Time] datetime value that can be used instead of the current time (optional)
439
    # @return [Dynamoid::Document] self
440
    def touch(*names, time: nil)
1✔
441
      if new_record?
10✔
442
        raise Dynamoid::Errors::Error, 'cannot touch on a new or destroyed record object'
1✔
443
      end
444

445
      time_to_assign = time || DateTime.now
9✔
446

447
      self.updated_at = time_to_assign
9✔
448
      names.each do |name|
9✔
449
        attributes[name] = time_to_assign
2✔
450
      end
451

452
      attribute_names = names.map(&:to_sym) + [:updated_at]
9✔
453
      attributes_with_values = attributes.slice(*attribute_names)
9✔
454

455
      run_callbacks :touch do
9✔
456
        self.class.update_fields(hash_key, range_value, attributes_with_values)
9✔
457
        clear_attribute_changes(attribute_names.map(&:to_s))
9✔
458
      end
459

460
      self
9✔
461
    end
462

463
    # Is this object persisted in DynamoDB?
464
    #
465
    #   user = User.new
466
    #   user.persisted? # => false
467
    #
468
    #   user.save
469
    #   user.persisted? # => true
470
    #
471
    # @return [true|false]
472
    # @since 0.2.0
473
    def persisted?
1✔
474
      !(new_record? || @destroyed)
27✔
475
    end
476

477
    # Create new model or persist changes.
478
    #
479
    # Run the validation and callbacks. Returns +true+ if saving is successful
480
    # and +false+ otherwise.
481
    #
482
    #   user = User.new
483
    #   user.save # => true
484
    #
485
    #   user.age = 26
486
    #   user.save # => true
487
    #
488
    # Validation can be skipped with +validate: false+ option:
489
    #
490
    #   user = User.new(age: -1)
491
    #   user.save(validate: false) # => true
492
    #
493
    # +save+ by default sets timestamps attributes - +created_at+ and
494
    # +updated_at+ when creates new model and updates +updated_at+ attribute
495
    # when update already existing one.
496
    #
497
    # Changing +updated_at+ attribute at updating a model can be skipped with
498
    # +touch: false+ option:
499
    #
500
    #   user.save(touch: false)
501
    #
502
    # If a model is new and hash key (+id+ by default) is not assigned yet
503
    # it was assigned implicitly with random UUID value.
504
    #
505
    # If +lock_version+ attribute is declared it will be incremented. If it's blank then it will be initialized with 1.
506
    #
507
    # +save+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
508
    # if primary key (hash key + optional range key) already exists in a
509
    # table.
510
    #
511
    # +save+ method call raises +Dynamoid::Errors::StaleObjectError+ exception
512
    # if there is +lock_version+ attribute and the document in a table was
513
    # already changed concurrently and +lock_version+ was consequently
514
    # increased.
515
    #
516
    # When a table is not created yet the first +save+ method call will create
517
    # a table. It's useful in test environment to avoid explicit table
518
    # creation.
519
    #
520
    # @param options [Hash] (optional)
521
    # @option options [true|false] :validate validate a model or not - +true+ by default (optional)
522
    # @option options [true|false] :touch update tiemstamps fields or not - +true+ by default (optional)
523
    # @return [true|false] Whether saving successful or not
524
    # @since 0.2.0
525
    def save(options = {})
1✔
526
      if Dynamoid.config.create_table_on_save
3,090✔
527
        self.class.create_table(sync: true)
3,088✔
528
      end
529

530
      create_or_update = new_record? ? :create : :update
3,090✔
531

532
      run_callbacks(:save) do
3,090✔
533
        run_callbacks(create_or_update) do
3,089✔
534
          Save.call(self, touch: options[:touch])
3,089✔
535
        end
536
      end
537
    end
538

539
    # Update multiple attributes at once, saving the object once the updates
540
    # are complete. Returns +true+ if saving is successful and +false+
541
    # otherwise.
542
    #
543
    #   user.update_attributes(age: 27, last_name: 'Tylor')
544
    #
545
    # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
546
    # attributes is not on the model
547
    #
548
    # @param attributes [Hash] a hash of attributes to update
549
    # @return [true|false] Whether updating successful or not
550
    # @since 0.2.0
551
    def update_attributes(attributes)
1✔
552
      attributes.each { |attribute, value| write_attribute(attribute, value) }
71✔
553
      save
32✔
554
    end
555

556
    # Update multiple attributes at once, saving the object once the updates
557
    # are complete.
558
    #
559
    #   user.update_attributes!(age: 27, last_name: 'Tylor')
560
    #
561
    # Raises a +Dynamoid::Errors::DocumentNotValid+ exception if some vaidation
562
    # fails.
563
    #
564
    # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
565
    # attributes is not on the model
566
    #
567
    # @param attributes [Hash] a hash of attributes to update
568
    def update_attributes!(attributes)
1✔
569
      attributes.each { |attribute, value| write_attribute(attribute, value) }
106✔
570
      save!
50✔
571
    end
572

573
    # Update a single attribute, saving the object afterwards.
574
    #
575
    # Returns +true+ if saving is successful and +false+ otherwise.
576
    #
577
    #   user.update_attribute(:last_name, 'Tylor')
578
    #
579
    # Validation is skipped.
580
    #
581
    # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
582
    # attributes is not on the model
583
    #
584
    # @param attribute [Symbol] attribute name to update
585
    # @param value [Object] the value to assign it
586
    # @return [Dynamoid::Document] self
587
    #
588
    # @since 0.2.0
589
    def update_attribute(attribute, value)
1✔
590
      # final implementation is in the Dynamoid::Validation module
591
      write_attribute(attribute, value)
×
592
      save
×
593
      self
×
594
    end
595

596
    # Update a model.
597
    #
598
    # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
599
    #
600
    # Accepts mandatory block in order to specify operations which will modify
601
    # attributes. Supports following operations: +add+, +delete+ and +set+.
602
    #
603
    # Operation +add+ just adds a value for numeric attributes and join
604
    # collections if attribute is a collection (one of +array+, +set+ or
605
    # +map+).
606
    #
607
    #   user.update! do |t|
608
    #     t.add(age: 1, followers_count: 5)
609
    #     t.add(hobbies: ['skying', 'climbing'])
610
    #   end
611
    #
612
    # Operation +delete+ is applied to collection attribute types and
613
    # substructs one collection from another.
614
    #
615
    #   user.update! do |t|
616
    #     t.delete(hobbies: ['skying'])
617
    #   end
618
    #
619
    # Operation +set+ just changes an attribute value:
620
    #
621
    #   user.update! do |t|
622
    #     t.set(age: 21)
623
    #   end
624
    #
625
    # All the operations work like +ADD+, +DELETE+ and +PUT+ actions supported
626
    # by +AttributeUpdates+
627
    # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
628
    # of +UpdateItem+ operation.
629
    #
630
    # It's an atomic operation. So adding or deleting elements in a collection
631
    # or incrementing or decrementing a numeric field is atomic and does not
632
    # interfere with other write requests.
633
    #
634
    # Can update a model conditionaly:
635
    #
636
    #   user.update!(if: { age: 20 }) do |t|
637
    #     t.add(age: 1)
638
    #   end
639
    #
640
    # To check if some attribute (or attributes) isn't stored in a DynamoDB
641
    # item (e.g. it wasn't set explicitly) there is another condition -
642
    # +unless_exists+:
643
    #
644
    #   user = User.create(name: 'Tylor')
645
    #   user.update!(unless_exists: [:age]) do |t|
646
    #     t.set(age: 18)
647
    #   end
648
    #
649
    # If a document doesn't meet conditions it raises
650
    # +Dynamoid::Errors::StaleObjectError+ exception.
651
    #
652
    # It will increment the +lock_version+ attribute if a table has the column,
653
    # but will not check it. Thus, a concurrent +save+ call will never cause an
654
    # +update!+ to fail, but an +update!+ may cause a concurrent +save+ to
655
    # fail.
656
    #
657
    # @param conditions [Hash] Conditions on model attributes to make a conditional update (optional)
658
    # @return [Dynamoid::Document] self
659
    def update!(conditions = {})
1✔
660
      run_callbacks(:update) do
33✔
661
        options = {}
33✔
662
        if range_key
33✔
663
          value = read_attribute(range_key)
4✔
664
          attribute_options = self.class.attributes[range_key]
4✔
665
          options[:range_key] = Dumping.dump_field(value, attribute_options)
4✔
666
        end
667

668
        begin
669
          table_name = self.class.table_name
33✔
670
          update_item_options = options.merge(conditions: conditions)
33✔
671

672
          new_attrs = Dynamoid.adapter.update_item(table_name, hash_key, update_item_options) do |t|
33✔
673
            item_updater = ItemUpdaterWithDumping.new(self.class, t)
33✔
674

675
            item_updater.add(lock_version: 1) if self.class.attributes[:lock_version]
33✔
676

677
            if self.class.timestamps_enabled?
33✔
678
              item_updater.set(updated_at: DateTime.now.in_time_zone(Time.zone))
31✔
679
            end
680

681
            yield t
33✔
682
          end
683
          load(Undumping.undump_attributes(new_attrs, self.class.attributes))
27✔
684
        rescue Dynamoid::Errors::ConditionalCheckFailedException
685
          raise Dynamoid::Errors::StaleObjectError.new(self, 'update')
6✔
686
        end
687
      end
688

689
      self
27✔
690
    end
691

692
    # Update a model.
693
    #
694
    # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
695
    #
696
    # Accepts mandatory block in order to specify operations which will modify
697
    # attributes. Supports following operations: +add+, +delete+ and +set+.
698
    #
699
    # Operation +add+ just adds a value for numeric attributes and join
700
    # collections if attribute is a collection (one of +array+, +set+ or
701
    # +map+).
702
    #
703
    #   user.update do |t|
704
    #     t.add(age: 1, followers_count: 5)
705
    #     t.add(hobbies: ['skying', 'climbing'])
706
    #   end
707
    #
708
    # Operation +delete+ is applied to collection attribute types and
709
    # substructs one collection from another.
710
    #
711
    #   user.update do |t|
712
    #     t.delete(hobbies: ['skying'])
713
    #   end
714
    #
715
    # If it's applied to a scalar attribute then the item's attribute is
716
    # removed at all:
717
    #
718
    #   user.update do |t|
719
    #     t.delete(age: nil)
720
    #   end
721
    #
722
    # or even without useless value at all:
723
    #
724
    #   user.update do |t|
725
    #     t.delete(:age)
726
    #   end
727
    #
728
    # Operation +set+ just changes an attribute value:
729
    #
730
    #   user.update do |t|
731
    #     t.set(age: 21)
732
    #   end
733
    #
734
    # All the operations works like +ADD+, +DELETE+ and +PUT+ actions supported
735
    # by +AttributeUpdates+
736
    # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
737
    # of +UpdateItem+ operation.
738
    #
739
    # Can update a model conditionaly:
740
    #
741
    #   user.update(if: { age: 20 }) do |t|
742
    #     t.add(age: 1)
743
    #   end
744
    #
745
    # To check if some attribute (or attributes) isn't stored in a DynamoDB
746
    # item (e.g. it wasn't set explicitly) there is another condition -
747
    # +unless_exists+:
748
    #
749
    #   user = User.create(name: 'Tylor')
750
    #   user.update(unless_exists: [:age]) do |t|
751
    #     t.set(age: 18)
752
    #   end
753
    #
754
    # If a document doesn't meet conditions it just returns +false+. Otherwise it returns +true+.
755
    #
756
    # It will increment the +lock_version+ attribute if a table has the column,
757
    # but will not check it. Thus, a concurrent +save+ call will never cause an
758
    # +update!+ to fail, but an +update!+ may cause a concurrent +save+ to
759
    # fail.
760
    #
761
    # @param conditions [Hash] Conditions on model attributes to make a conditional update (optional)
762
    # @return [true|false] - whether conditions are met and updating is successful
763
    def update(conditions = {}, &block)
1✔
764
      update!(conditions, &block)
26✔
765
      true
21✔
766
    rescue Dynamoid::Errors::StaleObjectError
767
      false
5✔
768
    end
769

770
    # Change numeric attribute value.
771
    #
772
    # Initializes attribute to zero if +nil+ and adds the specified value (by
773
    # default is 1). Only makes sense for number-based attributes.
774
    #
775
    #   user.increment(:followers_count)
776
    #   user.increment(:followers_count, 2)
777
    #
778
    # @param attribute [Symbol] attribute name
779
    # @param by [Numeric] value to add (optional)
780
    # @return [Dynamoid::Document] self
781
    def increment(attribute, by = 1)
1✔
782
      self[attribute] ||= 0
40✔
783
      self[attribute] += by
40✔
784
      self
40✔
785
    end
786

787
    # Change numeric attribute value and save a model.
788
    #
789
    # Initializes attribute to zero if +nil+ and adds the specified value (by
790
    # default is 1). Only makes sense for number-based attributes.
791
    #
792
    #   user.increment!(:followers_count)
793
    #   user.increment!(:followers_count, 2)
794
    #
795
    # Only `attribute` is saved. The model itself is not saved. So any other
796
    # modified attributes will still be dirty. Validations and callbacks are
797
    # skipped.
798
    #
799
    # When `:touch` option is passed the timestamp columns are updating. If
800
    # attribute names are passed, they are updated along with updated_at
801
    # attribute:
802
    #
803
    #   user.increment!(:followers_count, touch: true)
804
    #   user.increment!(:followers_count, touch: :viewed_at)
805
    #   user.increment!(:followers_count, touch: [:viewed_at, :accessed_at])
806
    #
807
    # @param attribute [Symbol] attribute name
808
    # @param by [Numeric] value to add (optional)
809
    # @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
810
    # @return [Dynamoid::Document] self
811
    def increment!(attribute, by = 1, touch: nil)
1✔
812
      increment(attribute, by)
30✔
813
      change = read_attribute(attribute) - (attribute_was(attribute) || 0)
30✔
814

815
      run_callbacks :touch do
30✔
816
        self.class.inc(hash_key, range_value, attribute => change, touch: touch)
30✔
817
        clear_attribute_changes(attribute)
30✔
818
      end
819

820
      self
30✔
821
    end
822

823
    # Change numeric attribute value.
824
    #
825
    # Initializes attribute to zero if +nil+ and subtracts the specified value
826
    # (by default is 1). Only makes sense for number-based attributes.
827
    #
828
    #   user.decrement(:followers_count)
829
    #   user.decrement(:followers_count, 2)
830
    #
831
    # @param attribute [Symbol] attribute name
832
    # @param by [Numeric] value to subtract (optional)
833
    # @return [Dynamoid::Document] self
834
    def decrement(attribute, by = 1)
1✔
835
      increment(attribute, -by)
5✔
836
    end
837

838
    # Change numeric attribute value and save a model.
839
    #
840
    # Initializes attribute to zero if +nil+ and subtracts the specified value
841
    # (by default is 1). Only makes sense for number-based attributes.
842
    #
843
    #   user.decrement!(:followers_count)
844
    #   user.decrement!(:followers_count, 2)
845
    #
846
    # Only `attribute` is saved. The model itself is not saved. So any other
847
    # modified attributes will still be dirty. Validations and callbacks are
848
    # skipped.
849
    #
850
    # When `:touch` option is passed the timestamp columns are updating. If
851
    # attribute names are passed, they are updated along with updated_at
852
    # attribute:
853
    #
854
    #   user.decrement!(:followers_count, touch: true)
855
    #   user.decrement!(:followers_count, touch: :viewed_at)
856
    #   user.decrement!(:followers_count, touch: [:viewed_at, :accessed_at])
857
    #
858
    # @param attribute [Symbol] attribute name
859
    # @param by [Numeric] value to subtract (optional)
860
    # @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
861
    # @return [Dynamoid::Document] self
862
    def decrement!(attribute, by = 1, touch: nil)
1✔
863
      increment!(attribute, -by, touch: touch)
15✔
864
    end
865

866
    # Delete a model.
867
    #
868
    # Runs callbacks.
869
    #
870
    # Supports optimistic locking with the +lock_version+ attribute and doesn't
871
    # delete a model if it's already changed.
872
    #
873
    # Returns +self+ if deleted successfully and +false+ otherwise.
874
    #
875
    # @return [Dynamoid::Document|false] whether deleted successfully
876
    # @since 0.2.0
877
    def destroy
1✔
878
      ret = run_callbacks(:destroy) do
15✔
879
        delete
13✔
880
      end
881

882
      @destroyed = true
14✔
883

884
      ret == false ? false : self
14✔
885
    end
886

887
    # Delete a model.
888
    #
889
    # Runs callbacks.
890
    #
891
    # Supports optimistic locking with the +lock_version+ attribute and doesn't
892
    # delete a model if it's already changed.
893
    #
894
    # Raises +Dynamoid::Errors::RecordNotDestroyed+ exception if model deleting
895
    # failed.
896
    def destroy!
1✔
897
      destroy || (raise Dynamoid::Errors::RecordNotDestroyed, self)
2✔
898
    end
899

900
    # Delete a model.
901
    #
902
    # Supports optimistic locking with the +lock_version+ attribute and doesn't
903
    # delete a model if it's already changed.
904
    #
905
    # Raises +Dynamoid::Errors::StaleObjectError+ exception if cannot delete a
906
    # model.
907
    #
908
    # @return [Dynamoid::Document] self
909
    # @since 0.2.0
910
    def delete
1✔
911
      options = range_key ? { range_key: Dumping.dump_field(read_attribute(range_key), self.class.attributes[range_key]) } : {}
38✔
912

913
      # Add an optimistic locking check if the lock_version column exists
914
      if self.class.attributes[:lock_version]
38✔
915
        conditions = { if: {} }
4✔
916
        conditions[:if][:lock_version] =
4✔
917
          if changes[:lock_version].nil?
4✔
918
            lock_version
3✔
919
          else
1✔
920
            changes[:lock_version][0]
1✔
921
          end
922
        options[:conditions] = conditions
4✔
923
      end
924

925
      @destroyed = true
38✔
926

927
      Dynamoid.adapter.delete(self.class.table_name, hash_key, options)
38✔
928

929
      self.class.associations.each_key do |name|
37✔
930
        send(name).disassociate_source
129✔
931
      end
932

933
      self
37✔
934
    rescue Dynamoid::Errors::ConditionalCheckFailedException
935
      raise Dynamoid::Errors::StaleObjectError.new(self, 'delete')
1✔
936
    end
937
  end
938
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc