require 'securerandom' require 'aws-sdk' require 'lotus/utils/hash' module Lotus module Model module Adapters module Dynamodb # Acts like table, using AWS::DynamoDB::Client. # # @api private # @since 0.1.0 class Collection include AWS::DynamoDB::Types # Response interface provides count and entities. # # @api private # @since 0.1.0 class Response attr_accessor :count, :entities, :last_evaluated_key def initialize @count = 0 @entities = [] end end # @attr_reader name [String] the name of the collection (eg. `users`) # # @since 0.1.0 # @api private attr_reader :name # @attr_reader identity [Symbol] the primary key of the collection # (eg. `:id`) # # @since 0.1.0 # @api private attr_reader :identity # Initialize a collection. # # @param client [AWS::DynamoDB::Client] DynamoDB client # @param coercer [Lotus::Model::Adapters::Dynamodb::Coercer] # @param name [Symbol] the name of the collection (eg. `:users`) # @param identity [Symbol] the primary key of the collection # (eg. `:id`). # # @api private # @since 0.1.0 def initialize(client, coercer, name, identity) @client, @coercer = client, coercer @name, @identity = name.to_s, identity @key_schema = {} end # Creates a record for the given entity and returns a primary key. # # @param entity [Object] the entity to persist # # @see Lotus::Model::Adapters::Dynamodb::Command#create # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#put_item-instance_method # # @return the primary key of the just created record. # # @api private # @since 0.1.0 def create(entity) entity[identity] ||= SecureRandom.uuid @client.put_item( table_name: name, item: serialize_item(entity), ) entity[identity] end # Updates the record corresponding to the given entity. # # @param entity [Object] the entity to persist # # @see Lotus::Model::Adapters::Dynamodb::Command#update # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#update_item-instance_method # # @api private # @since 0.1.0 def update(entity) @client.update_item( table_name: name, key: serialize_key(entity), attribute_updates: serialize_attributes(entity), ) end # Deletes the record corresponding to the given entity. # # @param entity [Object] the entity to delete # # @see Lotus::Model::Adapters::Dynamodb::Command#delete # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#delete_item-instance_method # # @api private # @since 0.1.0 def delete(entity) @client.delete_item( table_name: name, key: serialize_key(entity), ) end # Returns an unique record from the given collection, with the given # id. # # @param key [Array] the identity of the object # # @see Lotus::Model::Adapters::Dynamodb::Command#get # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#get_item-instance_method # # @return [Hash] the serialized record # # @api private # @since 0.1.0 def get(key) return if key.any? { |v| v.to_s == "" } return if key.count != key_schema.count response = @client.get_item( table_name: name, key: serialize_key(key), ) deserialize_item(response[:item]) if response[:item] end # Performs DynamoDB query operation. # # @param options [Hash] AWS::DynamoDB::Client options # @param previous_response [Response] deserialized response from a previous operation # # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#query-instance_method # # @return [Array<Hash>] the serialized entities # # @api private # @since 0.1.0 def query( = {}, previous_response = nil) response = @client.query(.merge(table_name: name)) deserialize_response(response, previous_response) end # Performs DynamoDB scan operation. # # @param options [Hash] AWS::DynamoDB::Client options # @param previous_response [Response] deserialized response from a previous operation # # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#scan-instance_method # # @return [Array<Hash>] the serialized entities # # @api private # @since 0.1.0 def scan( = {}, previous_response = nil) response = @client.scan(.merge(table_name: name)) deserialize_response(response, previous_response) end # Fetches DynamoDB table schema. # # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html#describe_table-instance_method # # @return [Hash] table schema definition # # @api private # @since 0.1.0 def schema @schema ||= @client.describe_table(table_name: name).fetch(:table) end # Maps table key schema to hash with attribute name as key and key # type as value. # # @param index [String] index to check (defaults to table itself) # # @see Lotus::Model::Adapters::Dynamodb::Collection#schema # # @return [Hash] key schema definition # # @api private # @since 0.1.0 def key_schema(index = nil) return @key_schema[index] if @key_schema[index] current_schema = if index everything = Array(schema[:local_secondary_indexes]) + Array(schema[:global_secondary_indexes]) indexes = Hash[everything.map { |i| [i[:index_name], i] }] indexes[index][:key_schema] else schema[:key_schema] end @key_schema[index] ||= Hash[current_schema.to_a.map do |key| [key[:attribute_name].to_sym, key[:key_type]] end] end # Checks if given column is in key schema or not. # # @param column [String] column to check # @param index [String] index to check (defaults to table itself) # # @see Lotus::Model::Adapters::Dynamodb::Collection#key_schema # # @return [Boolean] # # @api private # @since 0.1.0 def key?(column, index = nil) key_schema(index).has_key?(column) end # Coerce and format attribute value to match DynamoDB type. # # @param column [String] the attribute column # @param value [Object] the attribute value # # @see AWS::DynamoDB::Types # # @return [Hash] the formatted attribute # # @api private # @since 0.1.0 def format_attribute(column, value) value = @coercer.public_send(:"serialize_#{ column }", value) format_attribute_value(value) end # Serialize given record to have proper attributes for 'item' query. # # @param record [Hash] the serialized record # # @see AWS::DynamoDB::Types # # @return [Hash] the serialized item # # @api private # @since 0.1.0 def serialize_item(record) Hash[record.delete_if { |_, v| v.nil? }.map do |k, v| [k.to_s, format_attribute_value(v)] end] end # Serialize given record or primary key to have proper attributes # for 'key' query. # # @param record [Hash,Array] the serialized record or primary key # # @see AWS::DynamoDB::Types # # @return [Hash] the serialized key # # @api private # @since 0.1.0 def serialize_key(record) Hash[key_schema.keys.each_with_index.map do |k, idx| v = record.is_a?(Hash) ? record[k] : record[idx] [k.to_s, format_attribute(k, v)] end] end # Serialize given entity to exclude key schema attributes. # # @param entity [Hash] the entity # # @see AWS::DynamoDB::Types # # @return [Hash] the serialized attributes # # @api private # @since 0.1.0 def serialize_attributes(entity) keys = key_schema.keys Hash[entity.reject { |k, _| keys.include?(k) }.map do |k, v| if v.nil? [k.to_s, { action: "DELETE" }] else [k.to_s, { value: format_attribute_value(v), action: "PUT" }] end end] end # Deserialize DynamoDB scan/query response. # # @param response [Hash] the serialized response # @param previous_response [Response] deserialized response from a previous operation # # @return [Response] the deserialized response # # @api private # @since 0.1.0 def deserialize_response(response, previous_response = nil) current_response = previous_response || Response.new current_response.count += response[:count] current_response.entities += response[:member].map do |item| deserialize_item(item) end if response[:member] current_response.last_evaluated_key = response[:last_evaluated_key] current_response end # Deserialize item from DynamoDB response. # # @param item [Hash] the serialized item # # @see AWS::DynamoDB::Types # # @return [Hash] the deserialized record # # @api private # @since 0.1.0 def deserialize_item(record) Lotus::Utils::Hash.new(values_from_response_hash(record)).symbolize! end end end end end end