Dynamoid/dynamoid

View on GitHub
lib/dynamoid/persistence.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

require 'bigdecimal'
require 'securerandom'
require 'yaml'

require 'dynamoid/persistence/import'
require 'dynamoid/persistence/update_fields'
require 'dynamoid/persistence/upsert'
require 'dynamoid/persistence/save'
require 'dynamoid/persistence/inc'
require 'dynamoid/persistence/update_validations'

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

    attr_accessor :new_record, :destroyed
    alias new_record? new_record
    alias destroyed? destroyed

    # @private
    UNIX_EPOCH_DATE = Date.new(1970, 1, 1).freeze

    module ClassMethods
      def table_name
        table_base_name = options[:name] || base_class.name.split('::').last.downcase.pluralize

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

      # Create a table.
      #
      # Uses a configuration specified in a model class (with the +table+
      # method) e.g. table name, schema (hash and range keys), global and local
      # secondary indexes, billing mode and write/read capacity.
      #
      # For instance here
      #
      #   class User
      #     include Dynamoid::Document
      #
      #     table key: :uuid
      #     range :last_name
      #
      #     field :first_name
      #     field :last_name
      #   end
      #
      #   User.create_table
      #
      # +create_table+ method call will create a table +dynamoid_users+ with
      # hash key +uuid+ and range key +name+, DynamoDB default billing mode and
      # Dynamoid default read/write capacity units (100/20).
      #
      # All the configuration can be overridden with +options+ argument.
      #
      #   User.create_table(table_name: 'users', read_capacity: 200, write_capacity: 40)
      #
      # Dynamoid creates a table synchronously by default. DynamoDB table
      # creation is an asynchronous operation and a client should wait until a
      # table status changes to +ACTIVE+ and a table becomes available. That's
      # why Dynamoid is polling a table status and returns results only when a
      # table becomes available.
      #
      # Polling is configured with +Dynamoid::Config.sync_retry_max_times+ and
      # +Dynamoid::Config.sync_retry_wait_seconds+ configuration options. If
      # table creation takes more time than configured waiting time then
      # Dynamoid stops polling and returns +true+.
      #
      # In order to return back asynchronous behaviour and not to wait until a
      # table is created the +sync: false+ option should be specified.
      #
      #   User.create_table(sync: false)
      #
      # Subsequent method calls for the same table will be ignored.
      #
      # @param options [Hash]
      #
      # @option options [Symbol] :table_name name of the table
      # @option options [Symbol] :id hash key name of the table
      # @option options [Symbol] :hash_key_type Dynamoid type of the hash key - +:string+, +:integer+ or any other scalar type
      # @option options [Hash] :range_key a Hash with range key name and type in format +{ <name> => <type> }+ e.g. +{ last_name: :string }+
      # @option options [String] :billing_mode billing mode of a table - either +PROVISIONED+ (default) or +PAY_PER_REQUEST+ (for On-Demand Mode)
      # @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+
      # @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+
      # @option options [Hash] :local_secondary_indexes
      # @option options [Hash] :global_secondary_indexes
      # @option options [true|false] :sync specifies should the method call be synchronous and wait until a table is completely created
      #
      # @return [true|false] Whether a table created successfully
      # @since 0.4.0
      def create_table(options = {})
        range_key_hash = if range_key
                           { range_key => PrimaryKeyTypeMapping.dynamodb_type(attributes[range_key][:type], attributes[range_key]) }
                         end

        options = {
          id: hash_key,
          table_name: table_name,
          billing_mode: capacity_mode,
          write_capacity: write_capacity,
          read_capacity: read_capacity,
          range_key: range_key_hash,
          hash_key_type: PrimaryKeyTypeMapping.dynamodb_type(attributes[hash_key][:type], attributes[hash_key]),
          local_secondary_indexes: local_secondary_indexes.values,
          global_secondary_indexes: global_secondary_indexes.values
        }.merge(options)

        created_successfuly = Dynamoid.adapter.create_table(options[:table_name], options[:id], options)

        if created_successfuly && self.options[:expires]
          attribute = self.options[:expires][:field]
          Dynamoid.adapter.update_time_to_live(options[:table_name], attribute)
        end

        self
      end

      # Deletes the table for the model.
      #
      # Dynamoid deletes a table asynchronously and doesn't wait until a table
      # is deleted completely.
      #
      # Subsequent method calls for the same table will be ignored.
      # @return [Model class] self
      def delete_table
        Dynamoid.adapter.delete_table(table_name)
        self
      end

      # @private
      def from_database(attrs = {})
        klass = choose_right_class(attrs)
        attrs_undumped = Undumping.undump_attributes(attrs, klass.attributes)
        klass.new(attrs_undumped).tap { |r| r.new_record = false }
      end

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

      # Create a model.
      #
      # Initializes a new model and immediately saves it to DynamoDB.
      #
      #   User.create(first_name: 'Mark', last_name: 'Tyler')
      #
      # Accepts both Hash and Array of Hashes and can create several models.
      #
      #   User.create([{ first_name: 'Alice' }, { first_name: 'Bob' }])
      #
      # Creates a model and pass it into a block to set other attributes.
      #
      #   User.create(first_name: 'Mark') do |u|
      #     u.age = 21
      #   end
      #
      # Validates model and runs callbacks.
      #
      # @param attrs [Hash|Array[Hash]] Attributes of the models
      # @param block [Proc] Block to process a document after initialization
      # @return [Dynamoid::Document] The created document
      # @since 0.2.0
      def create(attrs = {}, &block)
        if attrs.is_a?(Array)
          attrs.map { |attr| create(attr, &block) }
        else
          build(attrs, &block).tap(&:save)
        end
      end

      # Create a model.
      #
      # Initializes a new object and immediately saves it to the Dynamoid.
      # Raises an exception +Dynamoid::Errors::DocumentNotValid+ if validation
      # failed. Accepts both Hash and Array of Hashes and can create several
      # models.
      #
      # @param attrs [Hash|Array[Hash]] Attributes with which to create the object.
      # @param block [Proc] Block to process a document after initialization
      # @return [Dynamoid::Document] The created document
      # @since 0.2.0
      def create!(attrs = {}, &block)
        if attrs.is_a?(Array)
          attrs.map { |attr| create!(attr, &block) }
        else
          build(attrs, &block).tap(&:save!)
        end
      end

      # Update document with provided attributes.
      #
      # Instantiates document and saves changes. Runs validations and
      # callbacks. Don't save changes if validation fails.
      #
      #   User.update('1', age: 26)
      #
      # If range key is declared for a model it should be passed as well:
      #
      #   User.update('1', 'Tylor', age: 26)
      #
      # @param hash_key [Scalar value] hash key
      # @param range_key_value [Scalar value] range key (optional)
      # @param attrs [Hash]
      # @return [Dynamoid::Document] Updated document
      def update(hash_key, range_key_value = nil, attrs)
        model = find(hash_key, range_key: range_key_value, consistent_read: true)
        model.update_attributes(attrs)
        model
      end

      # Update document with provided attributes.
      #
      # Instantiates document and saves changes. Runs validations and
      # callbacks.
      #
      #   User.update!('1', age: 26)
      #
      # If range key is declared for a model it should be passed as well:
      #
      #   User.update('1', 'Tylor', age: 26)
      #
      # Raises +Dynamoid::Errors::DocumentNotValid+ exception if validation fails.
      #
      # @param hash_key [Scalar value] hash key
      # @param range_key_value [Scalar value] range key (optional)
      # @param attrs [Hash]
      # @return [Dynamoid::Document] Updated document
      def update!(hash_key, range_key_value = nil, attrs)
        model = find(hash_key, range_key: range_key_value, consistent_read: true)
        model.update_attributes!(attrs)
        model
      end

      # Update document.
      #
      # Doesn't run validations and callbacks.
      #
      #   User.update_fields('1', age: 26)
      #
      # If range key is declared for a model it should be passed as well:
      #
      #   User.update_fields('1', 'Tylor', age: 26)
      #
      # Can make a conditional update so a document will be updated only if it
      # meets the specified conditions. Conditions can be specified as a +Hash+
      # with +:if+ key:
      #
      #   User.update_fields('1', { age: 26 }, { if: { version: 1 } })
      #
      # Here +User+ model has an integer +version+ field and the document will
      # be updated only if the +version+ attribute currently has value 1.
      #
      # If a document with specified hash and range keys doesn't exist or
      # conditions were specified and failed the method call returns +nil+.
      #
      # To check if some attribute (or attributes) isn't stored in a DynamoDB
      # item (e.g. it wasn't set explicitly) there is another condition -
      # +unless_exists+:
      #
      #   user = User.create(name: 'Tylor')
      #   User.update_fields(user.id, { age: 18 }, { unless_exists: [:age] })
      #
      # +update_fields+ uses the +UpdateItem+ operation so it saves changes and
      # loads an updated document back with one HTTP request.
      #
      # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
      # attributes is not on the model
      #
      # @param hash_key_value [Scalar value] hash key
      # @param range_key_value [Scalar value] range key (optional)
      # @param attrs [Hash]
      # @param conditions [Hash] (optional)
      # @return [Dynamoid::Document|nil] Updated document
      def update_fields(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
        optional_params = [range_key_value, attrs, conditions].compact
        if optional_params.first.is_a?(Hash)
          range_key_value = nil
          attrs, conditions = optional_params[0..1]
        else
          range_key_value = optional_params.first
          attrs, conditions = optional_params[1..2]
        end

        UpdateFields.call(self,
                          partition_key: hash_key_value,
                          sort_key: range_key_value,
                          attributes: attrs,
                          conditions: conditions)
      end

      # Update an existing document or create a new one.
      #
      # If a document with specified hash and range keys doesn't exist it
      # creates a new document with specified attributes. Doesn't run
      # validations and callbacks.
      #
      #   User.upsert('1', age: 26)
      #
      # If range key is declared for a model it should be passed as well:
      #
      #   User.upsert('1', 'Tylor', age: 26)
      #
      # Can make a conditional update so a document will be updated only if it
      # meets the specified conditions. Conditions can be specified as a +Hash+
      # with +:if+ key:
      #
      #   User.upsert('1', { age: 26 }, { if: { version: 1 } })
      #
      # Here +User+ model has an integer +version+ field and the document will
      # be updated only if the +version+ attribute currently has value 1.
      #
      # To check if some attribute (or attributes) isn't stored in a DynamoDB
      # item (e.g. it wasn't set explicitly) there is another condition -
      # +unless_exists+:
      #
      #   user = User.create(name: 'Tylor')
      #   User.upsert(user.id, { age: 18 }, { unless_exists: [:age] })
      #
      # If conditions were specified and failed the method call returns +nil+.
      #
      # +upsert+ uses the +UpdateItem+ operation so it saves changes and loads
      # an updated document back with one HTTP request.
      #
      # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
      # attributes is not on the model
      #
      # @param hash_key_value [Scalar value] hash key
      # @param range_key_value [Scalar value] range key (optional)
      # @param attrs [Hash]
      # @param conditions [Hash] (optional)
      # @return [Dynamoid::Document|nil] Updated document
      def upsert(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
        optional_params = [range_key_value, attrs, conditions].compact
        if optional_params.first.is_a?(Hash)
          range_key_value = nil
          attrs, conditions = optional_params[0..1]
        else
          range_key_value = optional_params.first
          attrs, conditions = optional_params[1..2]
        end

        Upsert.call(self,
                    partition_key: hash_key_value,
                    sort_key: range_key_value,
                    attributes: attrs,
                    conditions: conditions)
      end

      # Increase a numeric field by specified value.
      #
      #   User.inc('1', age: 2)
      #
      # Can update several fields at once.
      #
      #   User.inc('1', age: 2, version: 1)
      #
      # If range key is declared for a model it should be passed as well:
      #
      #   User.inc('1', 'Tylor', age: 2)
      #
      # It's an atomic operation it does not interfere with other write
      # requests.
      #
      # Uses efficient low-level +UpdateItem+ operation and does only one HTTP
      # request.
      #
      # Doesn't run validations and callbacks. Doesn't update +created_at+ and
      # +updated_at+ as well.
      #
      # When `:touch` option is passed the timestamp columns are updating. If
      # attribute names are passed, they are updated along with updated_at
      # attribute:
      #
      #   User.inc('1', age: 2, touch: true)
      #   User.inc('1', age: 2, touch: :viewed_at)
      #   User.inc('1', age: 2, touch: [:viewed_at, :accessed_at])
      #
      # @param hash_key_value [Scalar value] hash key
      # @param range_key_value [Scalar value] range key (optional)
      # @param counters [Hash] value to increase by
      # @option counters [true | Symbol | Array[Symbol]] :touch to update update_at attribute and optionally the specified ones
      # @return [Model class] self
      def inc(hash_key_value, range_key_value = nil, counters)
        Inc.call(self, hash_key_value, range_key_value, counters)
        self
      end
    end

    # Update document timestamps.
    #
    # Set +updated_at+ attribute to current DateTime.
    #
    #   post.touch
    #
    # Can update other fields in addition with the same timestamp if their
    # names passed as arguments.
    #
    #   user.touch(:last_login_at, :viewed_at)
    #
    # Some specific value can be used to save:
    #
    #   user.touch(time: 1.hour.ago)
    #
    # No validation is performed and only +after_touch+ callback is called.
    #
    # The method must be used on a persisted object, otherwise
    # +Dynamoid::Errors::Error+ will be thrown.
    #
    # @param names [*Symbol] a list of attribute names to update (optional)
    # @param time [Time] datetime value that can be used instead of the current time (optional)
    # @return [Dynamoid::Document] self
    def touch(*names, time: nil)
      if new_record?
        raise Dynamoid::Errors::Error, 'cannot touch on a new or destroyed record object'
      end

      time_to_assign = time || DateTime.now

      self.updated_at = time_to_assign
      names.each do |name|
        attributes[name] = time_to_assign
      end

      attribute_names = names.map(&:to_sym) + [:updated_at]
      attributes_with_values = attributes.slice(*attribute_names)

      run_callbacks :touch do
        self.class.update_fields(hash_key, range_value, attributes_with_values)
        clear_attribute_changes(attribute_names.map(&:to_s))
      end

      self
    end

    # Is this object persisted in DynamoDB?
    #
    #   user = User.new
    #   user.persisted? # => false
    #
    #   user.save
    #   user.persisted? # => true
    #
    # @return [true|false]
    # @since 0.2.0
    def persisted?
      !(new_record? || @destroyed)
    end

    # Create new model or persist changes.
    #
    # Run the validation and callbacks. Returns +true+ if saving is successful
    # and +false+ otherwise.
    #
    #   user = User.new
    #   user.save # => true
    #
    #   user.age = 26
    #   user.save # => true
    #
    # Validation can be skipped with +validate: false+ option:
    #
    #   user = User.new(age: -1)
    #   user.save(validate: false) # => true
    #
    # +save+ by default sets timestamps attributes - +created_at+ and
    # +updated_at+ when creates new model and updates +updated_at+ attribute
    # when update already existing one.
    #
    # Changing +updated_at+ attribute at updating a model can be skipped with
    # +touch: false+ option:
    #
    #   user.save(touch: false)
    #
    # If a model is new and hash key (+id+ by default) is not assigned yet
    # it was assigned implicitly with random UUID value.
    #
    # If +lock_version+ attribute is declared it will be incremented. If it's blank then it will be initialized with 1.
    #
    # +save+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
    # if primary key (hash key + optional range key) already exists in a
    # table.
    #
    # +save+ method call raises +Dynamoid::Errors::StaleObjectError+ exception
    # if there is +lock_version+ attribute and the document in a table was
    # already changed concurrently and +lock_version+ was consequently
    # increased.
    #
    # When a table is not created yet the first +save+ method call will create
    # a table. It's useful in test environment to avoid explicit table
    # creation.
    #
    # @param options [Hash] (optional)
    # @option options [true|false] :validate validate a model or not - +true+ by default (optional)
    # @option options [true|false] :touch update tiemstamps fields or not - +true+ by default (optional)
    # @return [true|false] Whether saving successful or not
    # @since 0.2.0
    def save(options = {})
      if Dynamoid.config.create_table_on_save
        self.class.create_table(sync: true)
      end

      create_or_update = new_record? ? :create : :update

      run_callbacks(:save) do
        run_callbacks(create_or_update) do
          Save.call(self, touch: options[:touch])
        end
      end
    end

    # Update multiple attributes at once, saving the object once the updates
    # are complete. Returns +true+ if saving is successful and +false+
    # otherwise.
    #
    #   user.update_attributes(age: 27, last_name: 'Tylor')
    #
    # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
    # attributes is not on the model
    #
    # @param attributes [Hash] a hash of attributes to update
    # @return [true|false] Whether updating successful or not
    # @since 0.2.0
    def update_attributes(attributes)
      attributes.each { |attribute, value| write_attribute(attribute, value) }
      save
    end

    # Update multiple attributes at once, saving the object once the updates
    # are complete.
    #
    #   user.update_attributes!(age: 27, last_name: 'Tylor')
    #
    # Raises a +Dynamoid::Errors::DocumentNotValid+ exception if some vaidation
    # fails.
    #
    # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
    # attributes is not on the model
    #
    # @param attributes [Hash] a hash of attributes to update
    def update_attributes!(attributes)
      attributes.each { |attribute, value| write_attribute(attribute, value) }
      save!
    end

    # Update a single attribute, saving the object afterwards.
    #
    # Returns +true+ if saving is successful and +false+ otherwise.
    #
    #   user.update_attribute(:last_name, 'Tylor')
    #
    # Validation is skipped.
    #
    # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
    # attributes is not on the model
    #
    # @param attribute [Symbol] attribute name to update
    # @param value [Object] the value to assign it
    # @return [Dynamoid::Document] self
    #
    # @since 0.2.0
    def update_attribute(attribute, value)
      # final implementation is in the Dynamoid::Validation module
      write_attribute(attribute, value)
      save
      self
    end

    # Update a model.
    #
    # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
    #
    # Accepts mandatory block in order to specify operations which will modify
    # attributes. Supports following operations: +add+, +delete+ and +set+.
    #
    # Operation +add+ just adds a value for numeric attributes and join
    # collections if attribute is a collection (one of +array+, +set+ or
    # +map+).
    #
    #   user.update! do |t|
    #     t.add(age: 1, followers_count: 5)
    #     t.add(hobbies: ['skying', 'climbing'])
    #   end
    #
    # Operation +delete+ is applied to collection attribute types and
    # substructs one collection from another.
    #
    #   user.update! do |t|
    #     t.delete(hobbies: ['skying'])
    #   end
    #
    # Operation +set+ just changes an attribute value:
    #
    #   user.update! do |t|
    #     t.set(age: 21)
    #   end
    #
    # All the operations work like +ADD+, +DELETE+ and +PUT+ actions supported
    # by +AttributeUpdates+
    # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
    # of +UpdateItem+ operation.
    #
    # It's an atomic operation. So adding or deleting elements in a collection
    # or incrementing or decrementing a numeric field is atomic and does not
    # interfere with other write requests.
    #
    # Can update a model conditionaly:
    #
    #   user.update!(if: { age: 20 }) do |t|
    #     t.add(age: 1)
    #   end
    #
    # To check if some attribute (or attributes) isn't stored in a DynamoDB
    # item (e.g. it wasn't set explicitly) there is another condition -
    # +unless_exists+:
    #
    #   user = User.create(name: 'Tylor')
    #   user.update!(unless_exists: [:age]) do |t|
    #     t.set(age: 18)
    #   end
    #
    # If a document doesn't meet conditions it raises
    # +Dynamoid::Errors::StaleObjectError+ exception.
    #
    # It will increment the +lock_version+ attribute if a table has the column,
    # but will not check it. Thus, a concurrent +save+ call will never cause an
    # +update!+ to fail, but an +update!+ may cause a concurrent +save+ to
    # fail.
    #
    # @param conditions [Hash] Conditions on model attributes to make a conditional update (optional)
    # @return [Dynamoid::Document] self
    def update!(conditions = {})
      run_callbacks(:update) do
        options = {}
        if range_key
          value = read_attribute(range_key)
          attribute_options = self.class.attributes[range_key]
          options[:range_key] = Dumping.dump_field(value, attribute_options)
        end

        begin
          table_name = self.class.table_name
          update_item_options = options.merge(conditions: conditions)

          new_attrs = Dynamoid.adapter.update_item(table_name, hash_key, update_item_options) do |t|
            t.add(lock_version: 1) if self.class.attributes[:lock_version]

            if self.class.timestamps_enabled?
              time_now = DateTime.now.in_time_zone(Time.zone)
              time_now_dumped = Dumping.dump_field(time_now, self.class.attributes[:updated_at])
              t.set(updated_at: time_now_dumped)
            end

            yield t
          end
          load(Undumping.undump_attributes(new_attrs, self.class.attributes))
        rescue Dynamoid::Errors::ConditionalCheckFailedException
          raise Dynamoid::Errors::StaleObjectError.new(self, 'update')
        end
      end

      self
    end

    # Update a model.
    #
    # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
    #
    # Accepts mandatory block in order to specify operations which will modify
    # attributes. Supports following operations: +add+, +delete+ and +set+.
    #
    # Operation +add+ just adds a value for numeric attributes and join
    # collections if attribute is a collection (one of +array+, +set+ or
    # +map+).
    #
    #   user.update do |t|
    #     t.add(age: 1, followers_count: 5)
    #     t.add(hobbies: ['skying', 'climbing'])
    #   end
    #
    # Operation +delete+ is applied to collection attribute types and
    # substructs one collection from another.
    #
    #   user.update do |t|
    #     t.delete(hobbies: ['skying'])
    #   end
    #
    # If it's applied to a scalar attribute then the item's attribute is
    # removed at all:
    #
    #   user.update do |t|
    #     t.delete(age: nil)
    #   end
    #
    # or even without useless value at all:
    #
    #   user.update do |t|
    #     t.delete(:age)
    #   end
    #
    # Operation +set+ just changes an attribute value:
    #
    #   user.update do |t|
    #     t.set(age: 21)
    #   end
    #
    # All the operations works like +ADD+, +DELETE+ and +PUT+ actions supported
    # by +AttributeUpdates+
    # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
    # of +UpdateItem+ operation.
    #
    # Can update a model conditionaly:
    #
    #   user.update(if: { age: 20 }) do |t|
    #     t.add(age: 1)
    #   end
    #
    # To check if some attribute (or attributes) isn't stored in a DynamoDB
    # item (e.g. it wasn't set explicitly) there is another condition -
    # +unless_exists+:
    #
    #   user = User.create(name: 'Tylor')
    #   user.update(unless_exists: [:age]) do |t|
    #     t.set(age: 18)
    #   end
    #
    # If a document doesn't meet conditions it just returns +false+. Otherwise it returns +true+.
    #
    # It will increment the +lock_version+ attribute if a table has the column,
    # but will not check it. Thus, a concurrent +save+ call will never cause an
    # +update!+ to fail, but an +update!+ may cause a concurrent +save+ to
    # fail.
    #
    # @param conditions [Hash] Conditions on model attributes to make a conditional update (optional)
    # @return [true|false] - whether conditions are met and updating is successful
    def update(conditions = {}, &block)
      update!(conditions, &block)
      true
    rescue Dynamoid::Errors::StaleObjectError
      false
    end

    # Change numeric attribute value.
    #
    # Initializes attribute to zero if +nil+ and adds the specified value (by
    # default is 1). Only makes sense for number-based attributes.
    #
    #   user.increment(:followers_count)
    #   user.increment(:followers_count, 2)
    #
    # @param attribute [Symbol] attribute name
    # @param by [Numeric] value to add (optional)
    # @return [Dynamoid::Document] self
    def increment(attribute, by = 1)
      self[attribute] ||= 0
      self[attribute] += by
      self
    end

    # Change numeric attribute value and save a model.
    #
    # Initializes attribute to zero if +nil+ and adds the specified value (by
    # default is 1). Only makes sense for number-based attributes.
    #
    #   user.increment!(:followers_count)
    #   user.increment!(:followers_count, 2)
    #
    # Only `attribute` is saved. The model itself is not saved. So any other
    # modified attributes will still be dirty. Validations and callbacks are
    # skipped.
    #
    # When `:touch` option is passed the timestamp columns are updating. If
    # attribute names are passed, they are updated along with updated_at
    # attribute:
    #
    #   user.increment!(:followers_count, touch: true)
    #   user.increment!(:followers_count, touch: :viewed_at)
    #   user.increment!(:followers_count, touch: [:viewed_at, :accessed_at])
    #
    # @param attribute [Symbol] attribute name
    # @param by [Numeric] value to add (optional)
    # @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
    # @return [Dynamoid::Document] self
    def increment!(attribute, by = 1, touch: nil)
      increment(attribute, by)
      change = read_attribute(attribute) - (attribute_was(attribute) || 0)

      run_callbacks :touch do
        self.class.inc(hash_key, range_value, attribute => change, touch: touch)
        clear_attribute_changes(attribute)
      end

      self
    end

    # Change numeric attribute value.
    #
    # Initializes attribute to zero if +nil+ and subtracts the specified value
    # (by default is 1). Only makes sense for number-based attributes.
    #
    #   user.decrement(:followers_count)
    #   user.decrement(:followers_count, 2)
    #
    # @param attribute [Symbol] attribute name
    # @param by [Numeric] value to subtract (optional)
    # @return [Dynamoid::Document] self
    def decrement(attribute, by = 1)
      increment(attribute, -by)
    end

    # Change numeric attribute value and save a model.
    #
    # Initializes attribute to zero if +nil+ and subtracts the specified value
    # (by default is 1). Only makes sense for number-based attributes.
    #
    #   user.decrement!(:followers_count)
    #   user.decrement!(:followers_count, 2)
    #
    # Only `attribute` is saved. The model itself is not saved. So any other
    # modified attributes will still be dirty. Validations and callbacks are
    # skipped.
    #
    # When `:touch` option is passed the timestamp columns are updating. If
    # attribute names are passed, they are updated along with updated_at
    # attribute:
    #
    #   user.decrement!(:followers_count, touch: true)
    #   user.decrement!(:followers_count, touch: :viewed_at)
    #   user.decrement!(:followers_count, touch: [:viewed_at, :accessed_at])
    #
    # @param attribute [Symbol] attribute name
    # @param by [Numeric] value to subtract (optional)
    # @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
    # @return [Dynamoid::Document] self
    def decrement!(attribute, by = 1, touch: nil)
      increment!(attribute, -by, touch: touch)
    end

    # Delete a model.
    #
    # Runs callbacks.
    #
    # Supports optimistic locking with the +lock_version+ attribute and doesn't
    # delete a model if it's already changed.
    #
    # Returns +self+ if deleted successfully and +false+ otherwise.
    #
    # @return [Dynamoid::Document|false] whether deleted successfully
    # @since 0.2.0
    def destroy
      ret = run_callbacks(:destroy) do
        delete
      end

      @destroyed = true

      ret == false ? false : self
    end

    # Delete a model.
    #
    # Runs callbacks.
    #
    # Supports optimistic locking with the +lock_version+ attribute and doesn't
    # delete a model if it's already changed.
    #
    # Raises +Dynamoid::Errors::RecordNotDestroyed+ exception if model deleting
    # failed.
    def destroy!
      destroy || (raise Dynamoid::Errors::RecordNotDestroyed, self)
    end

    # Delete a model.
    #
    # Supports optimistic locking with the +lock_version+ attribute and doesn't
    # delete a model if it's already changed.
    #
    # Raises +Dynamoid::Errors::StaleObjectError+ exception if cannot delete a
    # model.
    #
    # @return [Dynamoid::Document] self
    # @since 0.2.0
    def delete
      options = range_key ? { range_key: Dumping.dump_field(read_attribute(range_key), self.class.attributes[range_key]) } : {}

      # Add an optimistic locking check if the lock_version column exists
      if self.class.attributes[:lock_version]
        conditions = { if: {} }
        conditions[:if][:lock_version] =
          if changes[:lock_version].nil?
            lock_version
          else
            changes[:lock_version][0]
          end
        options[:conditions] = conditions
      end

      @destroyed = true

      Dynamoid.adapter.delete(self.class.table_name, hash_key, options)

      self.class.associations.each_key do |name|
        send(name).disassociate_source
      end

      self
    rescue Dynamoid::Errors::ConditionalCheckFailedException
      raise Dynamoid::Errors::StaleObjectError.new(self, 'delete')
    end
  end
end