Dynamoid/dynamoid

View on GitHub
lib/dynamoid/indexes.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true

module Dynamoid
  module Indexes
    extend ActiveSupport::Concern

    # @private
    # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey
    # Types allowed in indexes
    PERMITTED_KEY_DYNAMODB_TYPES = %i[
      string
      binary
      number
    ].freeze

    included do
      class_attribute :local_secondary_indexes, instance_accessor: false
      class_attribute :global_secondary_indexes, instance_accessor: false
      self.local_secondary_indexes = {}
      self.global_secondary_indexes = {}
    end

    module ClassMethods
      # Defines a Global Secondary index on a table. Keys can be specified as
      # hash-only, or hash & range.
      #
      #   class Post
      #     include Dynamoid::Document
      #
      #     field :category
      #
      #     global_secondary_index hash_key: :category
      #   end
      #
      # The full example with all the options being specified:
      #
      #   global_secondary_index hash_key: :category,
      #                          range_key: :created_at,
      #                          name: 'posts_category_created_at_index',
      #                          projected_attributes: :all,
      #                          read_capacity: 100,
      #                          write_capacity: 20
      #
      # Global secondary index should be declared after fields for mentioned
      # hash key and optional range key are declared (with method +field+)
      #
      # The only mandatory option is +hash_key+. Raises
      # +Dynamoid::Errors::InvalidIndex+ exception if passed incorrect
      # options.
      #
      # @param [Hash] options the options to pass for this table
      # @option options [Symbol] name the name for the index; this still gets
      #         namespaced. If not specified, will use a default name.
      # @option options [Symbol] hash_key the index hash key column.
      # @option options [Symbol] range_key the index range key column (if
      #         applicable).
      # @option options [Symbol, Array<Symbol>] projected_attributes table
      #         attributes to project for this index. Can be +:keys_only+, +:all+
      #         or an array of included fields. If not specified, defaults to
      #         +:keys_only+.
      # @option options [Integer] read_capacity set the read capacity for the
      #         index; does not work on existing indexes.
      # @option options [Integer] write_capacity set the write capacity for
      #         the index; does not work on existing indexes.
      def global_secondary_index(options = {})
        unless options.present?
          raise Dynamoid::Errors::InvalidIndex, 'empty index definition'
        end

        unless options[:hash_key].present?
          raise Dynamoid::Errors::InvalidIndex, 'A global secondary index requires a :hash_key to be specified'
        end

        index_opts = {
          read_capacity: Dynamoid::Config.read_capacity,
          write_capacity: Dynamoid::Config.write_capacity
        }.merge(options)

        index_opts[:dynamoid_class] = self
        index_opts[:type] = :global_secondary

        index = Dynamoid::Indexes::Index.new(index_opts)
        gsi_key = index_key(options[:hash_key], options[:range_key])
        global_secondary_indexes[gsi_key] = index
        self
      end

      # Defines a local secondary index on a table. Will use the same primary
      # hash key as the table.
      #
      #   class Comment
      #     include Dynamoid::Document
      #
      #     table hash_key: :post_id
      #     range :created_at, :datetime
      #     field :author_id
      #
      #     local_secondary_index range_key: :author_id
      #   end
      #
      # The full example with all the options being specified:
      #
      #   local_secondary_index range_key: :created_at,
      #                         name: 'posts_created_at_index',
      #                         projected_attributes: :all
      #
      # Local secondary index should be declared after fields for mentioned
      # hash key and optional range key are declared (with method +field+) as
      # well as after +table+ method call.
      #
      # The only mandatory option is +range_key+. Raises
      # +Dynamoid::Errors::InvalidIndex+ exception if passed incorrect
      # options.
      #
      # @param [Hash] options options to pass for this index.
      # @option options [Symbol] name the name for the index; this still gets
      #         namespaced. If not specified, a name is automatically generated.
      # @option options [Symbol] range_key the range key column for the index.
      # @option options [Symbol, Array<Symbol>] projected_attributes table
      #         attributes to project for this index. Can be +:keys_only+, +:all+
      #         or an array of included fields. If not specified, defaults to
      #         +:keys_only+.
      def local_secondary_index(options = {})
        unless options.present?
          raise Dynamoid::Errors::InvalidIndex, 'empty index definition'
        end

        primary_hash_key = hash_key
        primary_range_key = range_key
        index_range_key = options[:range_key]

        unless index_range_key.present?
          raise Dynamoid::Errors::InvalidIndex, 'A local secondary index ' \
                                                'requires a :range_key to be specified'
        end

        if primary_range_key.present? && index_range_key == primary_range_key
          raise Dynamoid::Errors::InvalidIndex, 'A local secondary index ' \
                                                'must use a different :range_key than the primary key'
        end

        index_opts = options.merge(
          dynamoid_class: self,
          type: :local_secondary,
          hash_key: primary_hash_key
        )

        index = Dynamoid::Indexes::Index.new(index_opts)
        key = index_key(primary_hash_key, index_range_key)
        local_secondary_indexes[key] = index
        self
      end

      # Returns an index by its hash key and optional range key.
      #
      # It works only for indexes without explicit name declared.
      #
      # @param hash [scalar] the hash key used to declare an index
      # @param range [scalar] the range key used to declare an index (optional)
      # @return [Dynamoid::Indexes::Index, nil] index object or nil if it isn't found
      def find_index(hash, range = nil)
        indexes[index_key(hash, range)]
      end

      # Returns an index by its name
      #
      # @param name [string, symbol] the name of the index to lookup
      # @return [Dynamoid::Indexes::Index, nil] index object or nil if it isn't found
      def find_index_by_name(name)
        string_name = name.to_s
        indexes.each_value.detect { |i| i.name.to_s == string_name }
      end

      # Returns true iff the provided hash[,range] key combo is a local
      # secondary index.
      #
      # @param [Symbol] hash hash key name.
      # @param [Symbol] range range key name.
      # @return [Boolean] true iff provided keys correspond to a local
      #         secondary index.
      def is_local_secondary_index?(hash, range = nil)
        local_secondary_indexes[index_key(hash, range)].present?
      end

      # Returns true iff the provided hash[,range] key combo is a global
      # secondary index.
      #
      # @param [Symbol] hash hash key name.
      # @param [Symbol] range range key name.
      # @return [Boolean] true iff provided keys correspond to a global
      #         secondary index.
      def is_global_secondary_index?(hash, range = nil)
        global_secondary_indexes[index_key(hash, range)].present?
      end

      # Generates a convenient lookup key name for a hash/range index.
      # Should normally not be used directly.
      #
      # @param [Symbol] hash hash key name.
      # @param [Symbol] range range key name.
      # @return [String] returns "hash" if hash only, "hash_range" otherwise.
      def index_key(hash, range = nil)
        name = hash.to_s
        name += "_#{range}" if range.present?
        name
      end

      # Generates a default index name.
      #
      # @param [Symbol] hash hash key name.
      # @param [Symbol] range range key name.
      # @return [String] index name of the form "table_name_index_index_key".
      def index_name(hash, range = nil)
        "#{table_name}_index_#{index_key(hash, range)}"
      end

      # Convenience method to return all indexes on the table.
      #
      # @return [Hash<String, Object>] the combined hash of global and local
      #         secondary indexes.
      def indexes
        local_secondary_indexes.merge(global_secondary_indexes)
      end

      # Returns an array of hash keys for all the declared Glocal Secondary
      # Indexes.
      #
      # @return [Array[String]] array of hash keys
      def indexed_hash_keys
        global_secondary_indexes.map do |_name, index|
          index.hash_key.to_s
        end
      end
    end

    # Represents the attributes of a DynamoDB index.
    class Index
      include ActiveModel::Validations

      PROJECTION_TYPES = %i[keys_only all].to_set
      DEFAULT_PROJECTION_TYPE = :keys_only

      attr_accessor :name, :dynamoid_class, :type, :hash_key, :range_key,
                    :hash_key_schema, :range_key_schema, :projected_attributes,
                    :read_capacity, :write_capacity

      validate do
        validate_index_type
        validate_hash_key
        validate_range_key
        validate_projected_attributes
      end

      def initialize(attrs = {})
        unless attrs[:dynamoid_class].present?
          raise Dynamoid::Errors::InvalidIndex, ':dynamoid_class is required'
        end

        @dynamoid_class = attrs[:dynamoid_class]
        @type = attrs[:type]
        @hash_key = attrs[:hash_key]
        @range_key = attrs[:range_key]
        @name = attrs[:name] || @dynamoid_class.index_name(@hash_key, @range_key)
        @projected_attributes =
          attrs[:projected_attributes] || DEFAULT_PROJECTION_TYPE
        @read_capacity = attrs[:read_capacity]
        @write_capacity = attrs[:write_capacity]

        raise Dynamoid::Errors::InvalidIndex, self unless valid?
      end

      # Convenience method to determine the projection type for an index.
      # Projection types are: :keys_only, :all, :include.
      #
      # @return [Symbol] the projection type.
      def projection_type
        if @projected_attributes.is_a? Array
          :include
        else
          @projected_attributes
        end
      end

      private

      def validate_projected_attributes
        unless @projected_attributes.is_a?(Array) ||
               PROJECTION_TYPES.include?(@projected_attributes)
          errors.add(:projected_attributes, 'Invalid projected attributes specified.')
        end
      end

      def validate_index_type
        unless @type.present? &&
               %i[local_secondary global_secondary].include?(@type)
          errors.add(:type, 'Invalid index :type specified')
        end
      end

      def validate_hash_key
        validate_index_key(:hash_key, @hash_key)
      end

      def validate_range_key
        validate_index_key(:range_key, @range_key)
      end

      def validate_index_key(key_param, key_val)
        return if key_val.blank?

        key_field_attributes = @dynamoid_class.attributes[key_val]
        if key_field_attributes.blank?
          errors.add(key_param, "No such field #{key_val} defined on table")
          return
        end

        key_dynamodb_type = dynamodb_type(key_field_attributes[:type], key_field_attributes)
        if PERMITTED_KEY_DYNAMODB_TYPES.include?(key_dynamodb_type)
          send(:"#{key_param}_schema=", { key_val => key_dynamodb_type })
        else
          errors.add(key_param, "Index :#{key_param} is not a valid key type")
        end
      end

      def dynamodb_type(field_type, options)
        PrimaryKeyTypeMapping.dynamodb_type(field_type, options)
      rescue Errors::UnsupportedKeyType
        field_type
      end
    end
  end
end