lib/dyna_model/table.rb in dyna_model-0.0.7 vs lib/dyna_model/table.rb in dyna_model-0.0.8

- old
+ new

@@ -16,10 +16,24 @@ s: "S", ss: "SS", ns: "NS" } + RETURN_VALUES = { + none: "NONE", + all_old: "ALL_OLD", + updated_old: "UPDATED_OLD", + all_new: "ALL_NEW", + updated_new: "UPDATED_NEW" + } + + RETURN_VALUES_UPDATE_ONLY = [ + :updated_old, + :all_new, + :updated_new + ] + QUERY_SELECT = { all: "ALL_ATTRIBUTES", projected: "ALL_PROJECTED_ATTRIBUTES", count: "COUNT", specific: "SPECIFIC_ATTRIBUTES" @@ -40,38 +54,38 @@ contains: "CONTAINS", not_contains: "NOT_CONTAINS", in: "IN" } + CONDITIONAL_OPERATOR = { + and: "AND", + or: "OR" + } + COMPARISON_OPERATOR_SCAN_ONLY = [ :ne, :not_null, :null, :contains, :not_contains, :in ] - class << self + def self.type_from_value(value) + case + when value.kind_of?(AWS::DynamoDB::Binary) then :b + when value.respond_to?(:to_str) then :s + when value.kind_of?(Numeric) then :n + else + raise ArgumentError, "unsupported attribute type #{value.class}" + end + end - + def self.attr_with_type(attr_name, value) + { attr_name => { TYPE_INDICATOR[type_from_value(value)] => value.to_s } } end - def self.type_from_value(value) - case - when value.kind_of?(AWS::DynamoDB::Binary) then :b - when value.respond_to?(:to_str) then :s - when value.kind_of?(Numeric) then :n - else - raise ArgumentError, "unsupported attribute type #{value.class}" - end - end - - def self.attr_with_type(attr_name, value) - { attr_name => { TYPE_INDICATOR[type_from_value(value)] => value.to_s } } - end - def initialize(model) @model = model @table_schema = model.table_schema self.load_schema self.validate_key_schema @@ -197,10 +211,11 @@ options[:order] ||= :desc #options[:index_name] ||= :none #AWS::DynamoDB::Errors::ValidationException: ALL_PROJECTED_ATTRIBUTES can be used only when Querying using an IndexName #options[:limit] ||= 10 #options[:exclusive_start_key] + #options[:query_filter] key_conditions = {} gsi = nil if options[:global_secondary_index] gsi = @table_schema[:global_secondary_indexes].select{ |gsi| gsi[:index_name].to_s == options[:global_secondary_index].to_s}.first @@ -219,39 +234,37 @@ return_consumed_capacity: RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]], scan_index_forward: (options[:order] == :asc) } if options[:range] - raise ArgumentError, "Expected a 2 element Hash for :range (ex {:age.gt => 13})" unless options[:range].is_a?(Hash) && options[:range].keys.size == 1 && options[:range].keys.first.is_a?(String) - range_key_name, comparison_operator = options[:range].keys.first.split(".") - raise ArgumentError, "Comparison operator must be one of (#{(COMPARISON_OPERATOR.keys - COMPARISON_OPERATOR_SCAN_ONLY).join(", ")})" unless COMPARISON_OPERATOR.keys.include?(comparison_operator.to_sym) - range_key = @range_keys.find{|k| k[:attribute_name] == range_key_name} + raise ArgumentError, "Table does not use a range key in its schema!" if @range_keys.blank? + attr_with_condition_hash = self.attr_with_condition(options[:range]) + range_key = @range_keys.find{|k| k[:attribute_name] == attr_with_condition_hash.keys.first} raise ArgumentError, ":range key must be a valid Range attribute" unless range_key - raise ArgumentError, ":range key must be a Range if using the operator BETWEEN" if comparison_operator == "between" && !options[:range].values.first.is_a?(Range) if range_key.has_key?(:index_name) # Local/Global Secondary Index options[:index_name] = range_key[:index_name] query_request[:index_name] = range_key[:index_name] end - range_value = options[:range].values.first - range_attribute_list = [] - if comparison_operator == "between" - range_attribute_list << { range_key[:attribute_type] => range_value.min } - range_attribute_list << { range_key[:attribute_type] => range_value.max } - else - # TODO - support Binary? - range_attribute_list = [{ range_key[:attribute_type] => range_value.to_s }] - end + key_conditions.merge!(attr_with_condition_hash) + end - key_conditions.merge!({ - range_key[:attribute_name] => { - attribute_value_list: range_attribute_list, - comparison_operator: COMPARISON_OPERATOR[comparison_operator.to_sym] - } - }) + query_filter = {} + conditional_operator = nil + if options[:query_filter] + raise ArgumentError, ":query_filter must be a hash" unless options[:query_filter].is_a?(Hash) + options[:query_filter].each_pair do |k,v| + query_filter.merge!(self.attr_with_condition({ k => v})) + end + if options[:conditional_operator] + raise ArgumentError, ":condition_operator invalid! Must be one of (#{CONDITIONAL_OPERATOR.keys.join(", ")})" unless CONDITIONAL_OPERATOR[options[:conditional_operator]] + conditional_operator = CONDITIONAL_OPERATOR[options[:conditional_operator]] + end end + query_request.merge!(query_filter: query_filter) unless query_filter.blank? + query_request.merge!(conditional_operator: conditional_operator) unless conditional_operator.blank? || query_filter.blank? if options[:global_secondary_index] # Override index_name if using GSI # You can only select projected attributes from a GSI options[:select] = :projected #if options[:select].blank? options[:index_name] = gsi[:index_name] @@ -313,12 +326,26 @@ @model.dynamo_db_client.batch_get_item(batch_get_item_request) end def write(attributes, options={}) options[:return_consumed_capacity] ||= :none + options[:return_values] ||= :none options[:update_item] = false unless options[:update_item] + expected = {} + conditional_operator = nil + if options[:expected] + raise ArgumentError, ":expected must be a hash" unless options[:expected].is_a?(Hash) + options[:expected].each_pair do |k,v| + expected.merge!(self.attr_with_condition({ k => v})) + end + if options[:conditional_operator] + raise ArgumentError, ":condition_operator invalid! Must be one of (#{CONDITIONAL_OPERATOR.keys.join(", ")})" unless CONDITIONAL_OPERATOR[options[:conditional_operator]] + conditional_operator = CONDITIONAL_OPERATOR[options[:conditional_operator]] + end + end + if options[:update_item] # UpdateItem key_request = { @hash_key[:attribute_name] => { @hash_key[:attribute_type] => options[:update_item][:hash_value].to_s, @@ -344,52 +371,82 @@ action: "PUT" } }) end end + raise ArgumentError, ":return_values must be one of (#{RETURN_VALUES.keys.join(", ")})" unless RETURN_VALUES[options[:return_values]] update_item_request = { table_name: @model.dynamo_db_table_name(options[:shard_name]), key: key_request, attribute_updates: attrs_to_update, - return_consumed_capacity: RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]] + return_consumed_capacity: RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]], + return_values: RETURN_VALUES[options[:return_values]] } + update_item_request.merge!(expected: expected) unless expected.blank? + update_item_request.merge!(conditional_operator: conditional_operator) unless conditional_operator.blank? || expected.blank? @model.dynamo_db_client.update_item(update_item_request) else # PutItem items = {} attributes.each_pair do |k,v| next if v.blank? # If empty string or nil, skip... items.merge!(self.class.attr_with_type(k,v)) end + raise ArgumentError, ":return_values must be one of (#{(RETURN_VALUES.keys - RETURN_VALUES_UPDATE_ONLY).join(", ")})" unless RETURN_VALUES[options[:return_values]] && !RETURN_VALUES_UPDATE_ONLY.include?(options[:return_values]) put_item_request = { table_name: @model.dynamo_db_table_name(options[:shard_name]), item: items, - return_consumed_capacity: RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]] + return_consumed_capacity: RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]], + return_values: RETURN_VALUES[options[:return_values]] } + put_item_request.merge!(expected: expected) unless expected.blank? + put_item_request.merge!(conditional_operator: conditional_operator) unless conditional_operator.blank? || expected.blank? @model.dynamo_db_client.put_item(put_item_request) end end def delete_item(options={}) raise ":delete_item => {...key_values...} required" unless options[:delete_item].present? + options[:return_consumed_capacity] ||= :none + options[:return_values] ||= :none + raise ArgumentError, ":return_values must be one of (#{(RETURN_VALUES.keys - RETURN_VALUES_UPDATE_ONLY).join(", ")})" unless RETURN_VALUES[options[:return_values]] && !RETURN_VALUES_UPDATE_ONLY.include?(options[:return_values]) key_request = { @hash_key[:attribute_name] => { @hash_key[:attribute_type] => options[:delete_item][:hash_value].to_s } } + if @primary_range_key raise ArgumentError, "range_key was not provided to the delete_item command" if options[:delete_item][:range_value].blank? key_request.merge!({ @primary_range_key[:attribute_name] => { @primary_range_key[:attribute_type] => options[:delete_item][:range_value].to_s } }) end + + expected = {} + conditional_operator = nil + if options[:expected] + raise ArgumentError, ":expected must be a hash" unless options[:expected].is_a?(Hash) + options[:expected].each_pair do |k,v| + expected.merge!(self.attr_with_condition({ k => v})) + end + if options[:conditional_operator] + raise ArgumentError, ":condition_operator invalid! Must be one of (#{CONDITIONAL_OPERATOR.keys.join(", ")})" unless CONDITIONAL_OPERATOR[options[:conditional_operator]] + conditional_operator = CONDITIONAL_OPERATOR[options[:conditional_operator]] + end + end + delete_item_request = { table_name: @model.dynamo_db_table_name(options[:shard_name]), - key: key_request + key: key_request, + return_consumed_capacity: RETURNED_CONSUMED_CAPACITY[options[:return_consumed_capacity]], + return_values: RETURN_VALUES[options[:return_values]] } + delete_item_request.merge!(expected: expected) unless expected.blank? + delete_item_request.merge!(conditional_operator: conditional_operator) unless conditional_operator.blank? || expected.blank? @model.dynamo_db_client.delete_item(delete_item_request) end # Perform a table scan # http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html @@ -418,42 +475,70 @@ scan_request.merge!({ select: QUERY_SELECT[options[:select]] }) end # :scan_filter => { :name.begins_with => "a" } scan_filter = {} + conditional_operator = nil if options[:scan_filter].present? options[:scan_filter].each_pair.each do |k,v| - # Hard to validate attribute types here, so infer by type sent and assume the user knows their own attrs - key_name, comparison_operator = k.split(".") - raise ArgumentError, "Comparison operator must be one of (#{COMPARISON_OPERATOR.keys.join(", ")})" unless COMPARISON_OPERATOR.keys.include?(comparison_operator.to_sym) - raise ArgumentError, "scan_filter value must be a Range if using the operator BETWEEN" if comparison_operator == "between" && !v.is_a?(Range) - raise ArgumentError, "scan_filter value must be a Array if using the operator IN" if comparison_operator == "in" && !v.is_a?(Array) + scan_filter.merge!(self.attr_with_condition({ k => v})) + end + end + if options[:conditional_operator] + raise ArgumentError, ":condition_operator invalid! Must be one of (#{CONDITIONAL_OPERATOR.keys.join(", ")})" unless CONDITIONAL_OPERATOR[options[:conditional_operator]] + conditional_operator = CONDITIONAL_OPERATOR[options[:conditional_operator]] + end + scan_request.merge!(scan_filter: scan_filter) unless scan_filter.blank? + scan_request.merge!(conditional_operator: conditional_operator) unless conditional_operator.blank? || scan_filter.blank? + scan_request.merge!(segment: options[:segment].to_i) if options[:segment].present? + scan_request.merge!(total_segments: options[:total_segments].to_i) if options[:total_segments].present? - attribute_value_list = [] - if comparison_operator == "in" - v.each do |in_v| - attribute_value_list << self.class.attr_with_type(key_name, in_v).values.last - end - elsif comparison_operator == "between" - attribute_value_list << self.class.attr_with_type(key_name, v.min).values.last - attribute_value_list << self.class.attr_with_type(key_name, v.max).values.last - else - attribute_value_list << self.class.attr_with_type(key_name, v).values.last - end - scan_filter.merge!({ - key_name => { - comparison_operator: COMPARISON_OPERATOR[comparison_operator.to_sym], - attribute_value_list: attribute_value_list - } - }) + @model.dynamo_db_client.scan(scan_request) + end + + protected + + # {:name.eq => "cary"} + # + # return: + # { + # "name" => { + # attribute_value_list: [ + # "S" => "cary" + # ], + # comparison_operator: "EQ" + # } + # } + def attr_with_condition(attr_conditional) + raise ArgumentError, "Expected a 2 element Hash for each :query_filter (ex {:age.gt => 13})" unless attr_conditional.is_a?(Hash) && attr_conditional.keys.size == 1 && attr_conditional.keys.first.is_a?(String) + attr_name, comparison_operator = attr_conditional.keys.first.split(".") + raise ArgumentError, "Comparison operator must be one of (#{(COMPARISON_OPERATOR.keys - COMPARISON_OPERATOR_SCAN_ONLY).join(", ")})" unless COMPARISON_OPERATOR.keys.include?(comparison_operator.to_sym) + attr_key = @model.attributes[attr_name] + raise ArgumentError, "#{attr_name} not a valid attribute" unless attr_key + attr_type = @model.attribute_type_indicator(attr_key) + raise ArgumentError, "#{attr_name} key must be a Range if using the operator BETWEEN" if comparison_operator == "between" && !attr_conditional.values.first.is_a?(Range) + raise ArgumentError, ":query_filter value must be an Array if using the operator IN" if comparison_operator == "in" && !attr_conditional.values.first.is_a?(Array) + + attr_value = attr_conditional.values.first + + attribute_value_list = [] + if comparison_operator == "in" + attr_conditional.values.first.each do |in_v| + attribute_value_list << { attr_type => in_v.to_s } end - scan_request.merge!(scan_filter: scan_filter) + elsif comparison_operator == "between" + attribute_value_list << { attr_type => attr_value.min.to_s } + attribute_value_list << { attr_type => attr_value.max.to_s } + else + attribute_value_list = [{ attr_type => attr_value.to_s }] end - scan_request.merge!({ segment: options[:segment].to_i }) if options[:segment].present? - scan_request.merge!({ total_segments: options[:total_segments].to_i }) if options[:total_segments].present? + attribute_comparison_hash = { + comparison_operator: COMPARISON_OPERATOR[comparison_operator.to_sym] + } + attribute_comparison_hash.merge!(attribute_value_list: attribute_value_list) unless %w(null not_null).include?(comparison_operator) - @model.dynamo_db_client.scan(scan_request) + { attr_name => attribute_comparison_hash } end end end