Skip to content

Commit

Permalink
Support multiple conditions for the same attribute in where
Browse files Browse the repository at this point in the history
  • Loading branch information
andrykonchin committed Apr 22, 2023
1 parent 17e4213 commit 9694b51
Show file tree
Hide file tree
Showing 12 changed files with 366 additions and 469 deletions.
30 changes: 18 additions & 12 deletions lib/dynamoid/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,25 @@ def method_missing(method, *args, &block)
# only really useful for range queries, since it can only find by one hash key at once. Only provide
# one range key to the hash.
#
# Dynamoid.adapter.query('users', { id: [[:eq, '1']], age: [[:between, [10, 30]]] }, { batch_size: 1000 })
#
# @param [String] table_name the name of the table
# @param [Hash] opts the options to query the table with
# @option opts [String] :hash_value the value of the hash key to find
# @option opts [Range] :range_value find the range key within this range
# @option opts [Number] :range_greater_than find range keys greater than this
# @option opts [Number] :range_less_than find range keys less than this
# @option opts [Number] :range_gte find range keys greater than or equal to this
# @option opts [Number] :range_lte find range keys less than or equal to this
#
# @return [Array] an array of all matching items
#
def query(table_name, opts = {})
adapter.query(table_name, opts)
# @param [Array[Array]] key_conditions conditions for the primary key attributes
# @param [Array[Array]] non_key_conditions (optional) conditions for non-primary key attributes
# @param [Hash] options (optional) the options to query the table with
# @option options [Boolean] :consistent_read You can set the ConsistentRead parameter to true and obtain a strongly consistent result
# @option options [Boolean] :scan_index_forward Specifies the order for index traversal: If true (default), the traversal is performed in ascending order; if false, the traversal is performed in descending order.
# @option options [Symbop] :select The attributes to be returned in the result (one of ALL_ATTRIBUTES, ALL_PROJECTED_ATTRIBUTES, ...)
# @option options [Symbol] :index_name The name of an index to query. This index can be any local secondary index or global secondary index on the table.
# @option options [Hash] :exclusive_start_key The primary key of the first item that this operation will evaluate.
# @option options [Integer] :batch_size The number of items to lazily load one by one
# @option options [Integer] :record_limit The maximum number of items to return (not necessarily the number of evaluated items)
# @option options [Integer] :scan_limit The maximum number of items to evaluate (not necessarily the number of matching items)
# @option options [Array[Symbol]] :project The attributes to retrieve from the table
#
# @return [Enumerable] matching items
def query(table_name, key_conditions, non_key_conditions = {}, options = {})
adapter.query(table_name, key_conditions, non_key_conditions, options)
end

def self.adapter_plugin_class
Expand Down
43 changes: 20 additions & 23 deletions lib/dynamoid/adapter_plugin/aws_sdk_v3.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,6 @@ module AdapterPlugin

class AwsSdkV3
EQ = 'EQ'
RANGE_MAP = {
range_greater_than: 'GT',
range_less_than: 'LT',
range_gte: 'GE',
range_lte: 'LE',
range_begins_with: 'BEGINS_WITH',
range_between: 'BETWEEN',
range_eq: 'EQ'
}.freeze

HASH_KEY = 'HASH'
RANGE_KEY = 'RANGE'
STRING_TYPE = 'S'
Expand All @@ -56,7 +46,7 @@ class AwsSdkV3
CONNECTION_CONFIG_OPTIONS = %i[endpoint region http_continue_timeout http_idle_timeout http_open_timeout http_read_timeout].freeze

# See https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html
RESERVED_WORDS = %i[
RESERVED_WORDS = Set.new(%i[
ABORT ABSOLUTE ACTION ADD AFTER AGENT AGGREGATE ALL ALLOCATE ALTER ANALYZE
AND ANY ARCHIVE ARE ARRAY AS ASC ASCII ASENSITIVE ASSERTION ASYMMETRIC AT
ATOMIC ATTACH ATTRIBUTE AUTH AUTHORIZATION AUTHORIZE AUTO AVG BACK BACKUP
Expand Down Expand Up @@ -114,7 +104,7 @@ class AwsSdkV3
USE USER USERS USING UUID VACUUM VALUE VALUED VALUES VARCHAR VARIABLE
VARIANCE VARINT VARYING VIEW VIEWS VIRTUAL VOID WAIT WHEN WHENEVER WHERE
WHILE WINDOW WITH WITHIN WITHOUT WORK WRAPPED WRITE YEAR ZONE
].freeze
]).freeze

attr_reader :table_cache

Expand Down Expand Up @@ -497,25 +487,32 @@ def put_item(table_name, object, options = {})
# only really useful for range queries, since it can only find by one hash key at once. Only provide
# one range key to the hash.
#
# Dynamoid.adapter.query('users', { id: [[:eq, '1']], age: [[:between, [10, 30]]] }, { batch_size: 1000 })
#
# @param [String] table_name the name of the table
# @param [Hash] options the options to query the table with
# @option options [String] :hash_value the value of the hash key to find
# @option options [Number, Number] :range_between find the range key within this range
# @option options [Number] :range_greater_than find range keys greater than this
# @option options [Number] :range_less_than find range keys less than this
# @option options [Number] :range_gte find range keys greater than or equal to this
# @option options [Number] :range_lte find range keys less than or equal to this
# @param [Array[Array]] key_conditions conditions for the primary key attributes
# @param [Array[Array]] non_key_conditions (optional) conditions for non-primary key attributes
# @param [Hash] options (optional) the options to query the table with
# @option options [Boolean] :consistent_read You can set the ConsistentRead parameter to true and obtain a strongly consistent result
# @option options [Boolean] :scan_index_forward Specifies the order for index traversal: If true (default), the traversal is performed in ascending order; if false, the traversal is performed in descending order.
# @option options [Symbop] :select The attributes to be returned in the result (one of ALL_ATTRIBUTES, ALL_PROJECTED_ATTRIBUTES, ...)
# @option options [Symbol] :index_name The name of an index to query. This index can be any local secondary index or global secondary index on the table.
# @option options [Hash] :exclusive_start_key The primary key of the first item that this operation will evaluate.
# @option options [Integer] :batch_size The number of items to lazily load one by one
# @option options [Integer] :record_limit The maximum number of items to return (not necessarily the number of evaluated items)
# @option options [Integer] :scan_limit The maximum number of items to evaluate (not necessarily the number of matching items)
# @option options [Array[Symbol]] :project The attributes to retrieve from the table
#
# @return [Enumerable] matching items
#
# @since 1.0.0
#
# @todo Provide support for various other options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#query-instance_method
def query(table_name, options = {})
def query(table_name, key_conditions, non_key_conditions = {}, options = {})
Enumerator.new do |yielder|
table = describe_table(table_name)

Query.new(client, table, options).call.each do |page|
Query.new(client, table, key_conditions, non_key_conditions, options).call.each do |page|
yielder.yield(
page.items.map { |item| item_to_hash(item) },
last_evaluated_key: page.last_evaluated_key
Expand All @@ -524,11 +521,11 @@ def query(table_name, options = {})
end
end

def query_count(table_name, options = {})
def query_count(table_name, key_conditions, non_key_conditions, options)
table = describe_table(table_name)
options[:select] = 'COUNT'

Query.new(client, table, options).call
Query.new(client, table, key_conditions, non_key_conditions, options).call
.map(&:count)
.reduce(:+)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,41 +20,41 @@ def initialize(conditions, name_placeholders, value_placeholders, name_placehold
private

def build
clauses = @conditions.map do |name, operation_and_value|
operator = operation_and_value.keys[0]
value = operation_and_value.values[0]
name_or_placeholder = name_or_placeholder_for(name)
clauses = @conditions.map do |name, attribute_conditions|
attribute_conditions.map do |operator, value|
name_or_placeholder = name_or_placeholder_for(name)

case operator
when :eq
"#{name_or_placeholder} = #{value_placeholder_for(value)}"
when :ne
"#{name_or_placeholder} <> #{value_placeholder_for(value)}"
when :gt
"#{name_or_placeholder} > #{value_placeholder_for(value)}"
when :lt
"#{name_or_placeholder} < #{value_placeholder_for(value)}"
when :gte
"#{name_or_placeholder} >= #{value_placeholder_for(value)}"
when :lte
"#{name_or_placeholder} <= #{value_placeholder_for(value)}"
when :between
"#{name_or_placeholder} BETWEEN #{value_placeholder_for(value[0])} AND #{value_placeholder_for(value[1])}"
when :begins_with
"begins_with (#{name_or_placeholder}, #{value_placeholder_for(value)})"
when :in
list = value.map(&method(:value_placeholder_for)).join(' , ')
"#{name_or_placeholder} IN (#{list})"
when :contains
"contains (#{name_or_placeholder}, #{value_placeholder_for(value)})"
when :not_contains
"NOT contains (#{name_or_placeholder}, #{value_placeholder_for(value)})"
when :null
"attribute_not_exists (#{name_or_placeholder})"
when :not_null
"attribute_exists (#{name_or_placeholder})"
case operator
when :eq
"#{name_or_placeholder} = #{value_placeholder_for(value)}"
when :ne
"#{name_or_placeholder} <> #{value_placeholder_for(value)}"
when :gt
"#{name_or_placeholder} > #{value_placeholder_for(value)}"
when :lt
"#{name_or_placeholder} < #{value_placeholder_for(value)}"
when :gte
"#{name_or_placeholder} >= #{value_placeholder_for(value)}"
when :lte
"#{name_or_placeholder} <= #{value_placeholder_for(value)}"
when :between
"#{name_or_placeholder} BETWEEN #{value_placeholder_for(value[0])} AND #{value_placeholder_for(value[1])}"
when :begins_with
"begins_with (#{name_or_placeholder}, #{value_placeholder_for(value)})"
when :in
list = value.map(&method(:value_placeholder_for)).join(' , ')
"#{name_or_placeholder} IN (#{list})"
when :contains
"contains (#{name_or_placeholder}, #{value_placeholder_for(value)})"
when :not_contains
"NOT contains (#{name_or_placeholder}, #{value_placeholder_for(value)})"
when :null
"attribute_not_exists (#{name_or_placeholder})"
when :not_null
"attribute_exists (#{name_or_placeholder})"
end
end
end
end.flatten

@expression = clauses.join(' AND ')
end
Expand Down
51 changes: 8 additions & 43 deletions lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,19 @@ module AdapterPlugin
class AwsSdkV3
class Query
OPTIONS_KEYS = %i[
limit hash_key hash_value range_key consistent_read scan_index_forward
select index_name batch_size exclusive_start_key record_limit scan_limit
project
consistent_read scan_index_forward select index_name batch_size
exclusive_start_key record_limit scan_limit project
].freeze

attr_reader :client, :table, :options, :conditions

def initialize(client, table, opts = {})
def initialize(client, table, key_conditions, non_key_conditions, options)
@client = client
@table = table

opts = opts.symbolize_keys
@options = opts.slice(*OPTIONS_KEYS)
@conditions = opts.except(*OPTIONS_KEYS)
@key_conditions = key_conditions
@non_key_conditions = non_key_conditions
@options = options.slice(*OPTIONS_KEYS)
end

def call
Expand Down Expand Up @@ -70,13 +69,13 @@ def build_request
limit = [record_limit, scan_limit, batch_size].compact.min

# key condition expression
convertor = FilterExpressionConvertor.new(key_conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence)
convertor = FilterExpressionConvertor.new(@key_conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence)
key_condition_expression = convertor.expression
value_placeholders = convertor.value_placeholders
name_placeholders = convertor.name_placeholders

# filter expression
convertor = FilterExpressionConvertor.new(non_key_conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence)
convertor = FilterExpressionConvertor.new(@non_key_conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence)
filter_expression = convertor.expression
value_placeholders = convertor.value_placeholders
name_placeholders = convertor.name_placeholders
Expand Down Expand Up @@ -112,40 +111,6 @@ def record_limit
def scan_limit
options[:scan_limit]
end

def hash_key_name
(options[:hash_key] || table.hash_key)
end

def range_key_name
(options[:range_key] || table.range_key)
end

def key_conditions
result = {}
result[hash_key_name] = { eq: options[:hash_value].freeze }

conditions.slice(*AwsSdkV3::RANGE_MAP.keys).each do |k, v|
op = {
range_greater_than: :gt,
range_less_than: :lt,
range_gte: :gte,
range_lte: :lte,
range_begins_with: :begins_with,
range_between: :between,
range_eq: :eq
}[k]

result[range_key_name] ||= {}
result[range_key_name][op] = v
end

result
end

def non_key_conditions
conditions.except(*AwsSdkV3::RANGE_MAP.keys)
end
end
end
end
Expand Down
Loading

0 comments on commit 9694b51

Please sign in to comment.