lib/aws/dynamo_db/attribute_collection.rb



# Copyright 2011-2012 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
#     http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

module AWS
  class DynamoDB

    # Represents the attributes of a DynamoDB item.  An attribute is a
    # name-value pair. The name must be a string, but the value can be
    # a string, number, string set, or number set.  Attribute values
    # cannot be null or empty.
    #
    # @note The SDK always returns numbers as BigDecimal objects and
    #   sets as Set objects; however, on input it accepts any numeric
    #   type for number attributes and either Arrays or Sets for set
    #   attributes.
    #
    # @example Retrieving specific attributes of an item
    #   (title, description) =
    #     item.attributes.values_at(:title, :description)
    #
    # @example Retrieving all attributes in hash form
    #   item.attributes.to_h
    #
    # @example Replacing the value of an attribute
    #   item.attributes[:title] = "Automobiles"
    #
    # @example Doing a mixed update of item attributes in a single operation
    #   item.attributes.update do |u|
    #
    #     # add 12 to the (numeric) value of "views"
    #     u.add(:views => 12)
    #
    #     # delete attributes
    #     u.delete(:foo, :bar)
    #
    #     # delete values out of a set attribute
    #     u.delete(:colors => ["red", "blue"])
    #
    #     # replace values
    #     u.set(:title => "More Automobiles")
    #   end
    #
    # @example Returning overwritten values
    #   item.attributes.to_h      # => { "foo" => "bar",
    #                                    "name" => "fred" }
    #   item.attributes.set(
    #     { "foo" => "baz" },
    #     :return => :updated_old
    #   )                         # => { "foo" => "bar" }
    #
    # @example Performing a conditional update
    #   item.set({ :version => 3 }, :if => { :version => 2 })
    class AttributeCollection

      include Core::Model
      include Enumerable
      include Types
      include Keys
      include Expectations

      # @return [Item] The item to which these attributes belong.
      attr_reader :item

      # @private
      def initialize(item, opts = {})
        @item = item
        super
      end

      # Behaves like Hash#each; yields each attribute as a name/value
      # pair.
      #
      #   attributes.each { |(name, value)| puts "#{name} = #{value}" }
      #
      #   attributes.each { |name, value| puts "#{name} = #{value}" }
      #
      # @param (see #to_h)
      #
      # @option options (see #to_h)
      def each(options = {}, &block)
        to_h(options).each(&block)
      end

      # @yieldparam [String] name Each attribute name.
      def each_key(options = {})
        each(options) { |k, v| yield(k) if block_given? }
      end

      # @yieldparam value Each attribute value belonging to the item.
      #   Values will be of type String, BigDecimal, Set<String> or
      #   Set<BigDecimal>.
      def each_value(options = {})
        each(options) { |k, v| yield(v) if block_given? }
      end

      # Retrieves the value of a single attribute.
      #
      # @param [String, Symbol] attribute The name of the attribute to
      #   get.
      #
      # @return The value of the specified attribute, which may be a
      #   String, BigDecimal, Set<String> or Set<BigDecimal>.
      def [] attribute
        attribute = attribute.to_s
        response_attributes = get_item(:attributes_to_get => [attribute])
        value_from_response(response_attributes[attribute])
      end

      # Replaces the value of a single attribute.
      #
      # @param [String, Symbol] attribute The name of the attribute to
      #   replace.
      #
      # @param value The new value to set.  This may be a String,
      #   Numeric, Set or Array of String objects, or Set or Array of
      #   Numeric objects.  Mixed types are not allowed in sets, and
      #   neither String values nor set values may be empty.  Setting
      #   an attribute to nil is the same as deleting the attribute.
      #
      # @return The new value of the attribute.
      def []= attribute, value
        set(attribute => value)
        value
      end

      # Replaces the values of one or more attributes.
      #
      # @param [Hash] attributes The attributes to replace.  The keys
      #   of the hash may be strings or symbols.  The values may be of
      #   type String, Numeric, Set or Array of String objects, or Set
      #   or Array of Numeric objects.  Mixed types are not allowed in
      #   sets, and neither String values nor set values may be empty.
      #   Setting an attribute to nil is the same as deleting the
      #   attribute.
      #
      # @param [Hash] options Options for updating the item.
      #
      # @option options (see #update)
      def set attributes, options = {}
        update(options) { |u| u.set(attributes) }
      end
      alias_method :merge!, :set
      alias_method :put, :set

      # Adds to the values of one or more attributes.  Each attribute
      # must be a set or number in the original item, and each input
      # value must have the same type as the value in the original
      # item.  For example, it is invalid to add a single number to a
      # set of numbers, but it is valid to add a set containing a
      # single number to another set of numbers.
      #
      # When the original attribute is a set, the values provided to
      # this method are added to that set.  When the original
      # attribute is a number, the original value is incremented by
      # the numeric value provided to this method.  For example:
      #
      #   item = table.items.put(
      #     :id => "abc123",
      #     :colors => ["red", "white"],
      #     :age => 3
      #   )
      #   item.attributes.add(
      #     { :colors => ["muave"],
      #       :age => 1 },
      #     :return => :updated_new
      #   ) # => { "colors" => Set["red", "white", "mauve"],
      #     #      "age" => 4 }
      #
      # @param [Hash] attributes The attribute values to add.  The
      #   keys of the hash may be strings or symbols.  The values may
      #   be of type Numeric, Set or Array of String objects, or Set
      #   or Array of Numeric objects.  Mixed types are not allowed in
      #   sets, and neither String values nor set values may be empty.
      #   Single string values are not allowed for this method, since
      #   DynamoDB does not currently support adding a string to
      #   another string.
      #
      # @param [Hash] options Options for updating the item.
      #
      # @option options (see #update)
      def add attributes, options = {}
        update(options) { |u| u.add(attributes) }
      end

      # @overload delete *attributes
      #
      #   Deletes attributes from the item.  Each argument must be a
      #   string or symbol specifying the name of the attribute to
      #   delete.  The last argument may be a hash containing options
      #   for the update.  See {#update} for more information about
      #   what options are accepted.
      #
      # @overload delete attributes, options = {}
      #
      #   Deletes specific values from one or more attributes, whose
      #   original values must be sets.
      #
      #   @param [Hash] attributes The attribute values to delete.
      #     The keys of the hash may be strings or symbols.  The
      #     values must be arrays or Sets of numbers or strings.
      #     Mixed types are not allowed in sets.  The type of each
      #     member in a set must match the type of the members in the
      #     original attribute value.
      #
      #   @param [Hash] options Options for updating the item.
      #
      #   @option options (see #update)
      def delete *args
        if args.first.kind_of?(Hash)
          delete_args = [args.shift]
        else
          delete_args = args
        end
        options = args.pop if args.last.kind_of?(Hash)
        update(options || {}) { |u| u.delete(*delete_args) }
      end

      # Used to build a batch of updates to an item's attributes.  See
      # {AttributeCollection#update} for more information.
      class UpdateBuilder

        # @private
        attr_reader :updates

        include Types

        # @private
        def initialize
          @updates = {}
        end

        # Replaces the values of one or more attributes.  See
        # {AttributeCollection#set} for more information.
        def set attributes
          to_delete = []
          attributes = attributes.inject({}) do |attributes, (name, value)|
            if value == nil
              to_delete << name
            else
              attributes[name] = value
            end
            attributes
          end
          attribute_updates("PUT", attributes)
          delete(*to_delete)
        end
        alias_method :put, :set
        alias_method :merge!, :set

        # Adds to the values of one or more attributes.  See
        # {AttributeCollection#add} for more information.
        def add attributes
          attribute_updates("ADD", attributes)
        end


        # Deletes one or more attributes or attribute values.  See
        # {AttributeCollection#delete} for more information.
        def delete *args
          if args.first.kind_of?(Hash)
            attribute_updates("DELETE",
                              args.shift,
                              :setify => true)
          else
            add_updates(args.inject({}) do |u, name|
                          u.update(name.to_s => {
                                     :action => "DELETE"
                                   })
                        end)
          end
        end

        private
        def attribute_updates(action, attributes, our_opts = {})
          new_updates = attributes.inject({}) do |new_updates, (name, value)|
            name = name.to_s
            context = "in value for attribute #{name}"
            value = [value].flatten if our_opts[:setify]
            new_updates.update(name => {
                                 :action => action,
                                 :value =>
                                 format_attribute_value(value, context)
                               })
          end
          add_updates(new_updates)
        end

        private
        def add_updates(new_updates)
          updates.merge!(new_updates) do |name, old, new|
            raise ArgumentError, "conflicting updates for attribute #{name}"
          end
        end

      end

      # Updates multiple attributes in a single operation.  This is
      # more efficient than performing each type of update in
      # sequence, and it also allows you to group different kinds of
      # updates into an atomic operation.
      #
      #   item.attributes.update do |u|
      #
      #     # add 12 to the (numeric) value of "views"
      #     u.add(:views => 12)
      #
      #     # delete attributes
      #     u.delete(:foo, :bar)
      #
      #     # delete values out of a set attribute
      #     u.delete(:colors => ["red", "blue"])
      #
      #     # replace values
      #     u.set(:title => "More Automobiles")
      #   end
      #
      # @param [Hash] options Options for updating the item.
      #
      # @option options [Hash] :if Designates a conditional update.
      #   The operation will fail unless the item exists and has the
      #   attributes in the value for this option.  For example:
      #
      #     # throws DynamoDB::Errors::ConditionalCheckFailedException
      #     # unless the item has "color" set to "red"
      #     item.attributes.update(:if => { :color => "red" }) do |u|
      #       # ...
      #     end
      #
      # @option options [String, Symbol, Array] :unless_exists A name
      #   or collection of attribute names; if the item already exists
      #   and has a value for any of these attributes, this method
      #   will raise
      #   +DynamoDB::Errors::ConditionalCheckFailedException+.  For example:
      #
      #     item.attributes.update(:unless_exists => :color) do |u|
      #       # ...
      #     end
      #
      # @option options [Symbol] :return (+:none+) Changes the return
      #   value of the method.  Valid values:
      #
      #   [+:none+] Return +nil+
      #
      #   [+:all_old+] Returns a hash containing all of the original
      #                values of the attributes before the update, or
      #                +nil+ if the item did not exist at the time of
      #                the update.
      #
      #   [+:updated_old+] Returns a hash containing the original
      #                    values of the attributes that were modified
      #                    as part of this operation.  This includes
      #                    attributes that were deleted, and
      #                    set-valued attributes whose member values
      #                    were deleted.
      #
      #   [+:updated_new+] Returns a hash containing the new values of
      #                    the attributes that were modified as part
      #                    of this operation.  This includes
      #                    set-valued attributes whose member values
      #                    were deleted.
      #
      #   [+:all_new+] Returns a hash containing the new values of all
      #                of the attributes.
      #
      # @yieldparam [UpdateBuilder] builder A handle for describing
      #   the update.
      #
      # @note DnamoDB allows only one update per attribute in a
      #   single operation.  This method will raise an ArgumentError
      #   if multiple updates are described for a single attribute.
      #
      # @return [nil] See the documentation for the +:return+ option
      #   above.
      def update(options = {})
        builder = UpdateBuilder.new
        yield(builder)
        do_updates({ :attribute_updates => builder.updates },
                   options)
      end

      # Retrieves the values of the specified attributes.
      #
      # @param [Array<String, Symbol>] attributes The names of the
      #   attributes to retrieve.  The last argument may be a hash of
      #   options for retrieving attributes from the item.  Currently
      #   the only supported option is +:consistent_read+; If set to
      #   true, then a consistent read is issued, otherwise an
      #   eventually consistent read is used.
      #
      # @return [Array] An array in which each member contains the
      #   value of the attribute at that index in the argument list.
      #   Values may be Strings, BigDecimals, Sets of Strings or Sets
      #   or BigDecimals.  If a requested attribute does not exist,
      #   the corresponding member of the output array will be +nil+.
      def values_at(*attributes)
        options = {}
        options = attributes.pop if attributes.last.kind_of?(Hash)

        return [] if attributes.empty?

        attributes.map! { |a| a.to_s }
        response_attributes =
          get_item(options, :attributes_to_get => attributes)

        values_from_response_hash(response_attributes).
          values_at(*attributes)
      end

      # @param [Hash] options Options for retrieving attributes from
      #   the item.
      #
      # @option options [Boolean] :consistent_read If set to true,
      #   then a consistent read is issued, otherwise an eventually
      #   consistent read is used.
      def to_h options = {}
        values_from_response_hash(get_item(options))
      end

      private
      def item_key_options(options = {})
        super(item, options)
      end

      private
      def get_item(their_options, our_options = {})
        options = their_options.merge(our_options)
        resp = client.get_item(item_key_options(options))
        resp.data['Item'] || {}
      end

      private
      def do_updates(client_opts, options)
        return nil if client_opts[:attribute_updates].empty?

        client_opts[:return_values] = options[:return].to_s.upcase if
          options[:return]

        expected = expect_conditions(options)
        client_opts[:expected] = expected unless expected.empty?

        resp = client.update_item(item_key_options(client_opts))

        values_from_response_hash(resp.data["Attributes"]) if
          options[:return] and resp.data["Attributes"]
      end

    end

  end
end