Skip to content

Commit 94e5da7

Browse files
authored
Merge pull request #832 from Dynamoid/ak/where-with-filter-expression
Support condition expressions with `#where`
2 parents 6f14c1e + 4399fcd commit 94e5da7

File tree

12 files changed

+289
-72
lines changed

12 files changed

+289
-72
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,13 @@ jobs:
175175
gemfile: rails_8_0
176176

177177
include:
178-
- ruby: "jruby-9.3.9.0"
178+
- ruby: "jruby-9.3.15.0"
179179
gemfile: rails_4_2
180-
- ruby: "jruby-9.3.9.0"
180+
- ruby: "jruby-9.3.15.0"
181181
gemfile: rails_5_0
182-
- ruby: "jruby-9.3.9.0"
182+
- ruby: "jruby-9.3.15.0"
183183
gemfile: rails_5_1
184-
- ruby: "jruby-9.3.9.0"
184+
- ruby: "jruby-9.3.15.0"
185185
gemfile: rails_5_2
186186

187187
name: ${{ matrix.gemfile }}, Ruby ${{ matrix.ruby }}

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ users = User.import([{ name: 'Josh' }, { name: 'Nick' }])
719719

720720
### Querying
721721

722-
Querying can be done in one of three ways:
722+
Querying can be done in one of the following ways:
723723

724724
```ruby
725725
Address.find(address.id) # Find directly by ID.
@@ -728,6 +728,27 @@ Address.where(city: 'Chicago').all # Find by any number of matching criteria.
728728
Address.find_by_city('Chicago') # The same as above, but using ActiveRecord's older syntax.
729729
```
730730

731+
There is also a way to `#where` with a condition expression:
732+
733+
```ruby
734+
Address.where('city = :c', c: 'Chicago')
735+
```
736+
737+
A condition expression may contain operators (e.g. `<`, `>=`, `<>`),
738+
keywords (e.g. `AND`, `OR`, `BETWEEN`) and built-in functions (e.g.
739+
`begins_with`, `contains`) (see (documentation
740+
)[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html]
741+
for full syntax description).
742+
743+
**Warning:** Values (specified for a String condition expression) are
744+
sent as is so Dynamoid field types that aren't supported natively by
745+
DynamoDB (e.g. `datetime` and `date`) require explicit casting.
746+
747+
**Warning:** String condition expressions will be used by DynamoDB only
748+
at filtering, so conditions on key attributes should be specified as a
749+
Hash to perform Query operation instead of Scan. Don't use key
750+
attributes in `#where`'s String condition expressions.
751+
731752
And you can also query on associations:
732753

733754
```ruby

lib/dynamoid/adapter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def delete(table, ids, options = {})
118118
# @param [Hash] query a hash of attributes: matching records will be returned by the scan
119119
#
120120
# @since 0.2.0
121-
def scan(table, query = {}, opts = {})
121+
def scan(table, query = [], opts = {})
122122
benchmark('Scan', table, query) { adapter.scan(table, query, opts) }
123123
end
124124

lib/dynamoid/adapter_plugin/aws_sdk_v3.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ def put_item(table_name, object, options = {})
517517
# @since 1.0.0
518518
#
519519
# @todo Provide support for various other options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#query-instance_method
520-
def query(table_name, key_conditions, non_key_conditions = {}, options = {})
520+
def query(table_name, key_conditions, non_key_conditions = [], options = {})
521521
Enumerator.new do |yielder|
522522
table = describe_table(table_name)
523523

@@ -550,7 +550,7 @@ def query_count(table_name, key_conditions, non_key_conditions, options)
550550
# @since 1.0.0
551551
#
552552
# @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#scan-instance_method
553-
def scan(table_name, conditions = {}, options = {})
553+
def scan(table_name, conditions = [], options = {})
554554
Enumerator.new do |yielder|
555555
table = describe_table(table_name)
556556

@@ -563,7 +563,7 @@ def scan(table_name, conditions = {}, options = {})
563563
end
564564
end
565565

566-
def scan_count(table_name, conditions = {}, options = {})
566+
def scan_count(table_name, conditions = [], options = {})
567567
table = describe_table(table_name)
568568
options[:select] = 'COUNT'
569569

lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,24 @@ def initialize(conditions, name_placeholders, value_placeholders, name_placehold
2020
private
2121

2222
def build
23-
clauses = @conditions.map do |name, attribute_conditions|
23+
clauses = []
24+
25+
@conditions.each do |conditions|
26+
if conditions.is_a? Hash
27+
clauses << build_for_hash(conditions) unless conditions.empty?
28+
elsif conditions.is_a? Array
29+
query, placeholders = conditions
30+
clauses << build_for_string(query, placeholders)
31+
else
32+
raise ArgumentError, "expected Hash or Array but actual value is #{conditions}"
33+
end
34+
end
35+
36+
@expression = clauses.join(' AND ')
37+
end
38+
39+
def build_for_hash(hash)
40+
clauses = hash.map do |name, attribute_conditions|
2441
attribute_conditions.map do |operator, value|
2542
# replace attribute names with placeholders unconditionally to support
2643
# - special characters (e.g. '.', ':', and '#') and
@@ -62,7 +79,21 @@ def build
6279
end
6380
end.flatten
6481

65-
@expression = clauses.join(' AND ')
82+
if clauses.empty?
83+
nil
84+
else
85+
clauses.join(' AND ')
86+
end
87+
end
88+
89+
def build_for_string(query, placeholders)
90+
placeholders.each do |(k, v)|
91+
k = k.to_s
92+
k = ":#{k}" unless k.start_with?(':')
93+
@value_placeholders[k] = v
94+
end
95+
96+
"(#{query})"
6697
end
6798

6899
def name_placeholder_for(name)

lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def build_request
6969
limit = [record_limit, scan_limit, batch_size].compact.min
7070

7171
# key condition expression
72-
convertor = FilterExpressionConvertor.new(@key_conditions, name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence)
72+
convertor = FilterExpressionConvertor.new([@key_conditions], name_placeholders, value_placeholders, name_placeholder_sequence, value_placeholder_sequence)
7373
key_condition_expression = convertor.expression
7474
value_placeholders = convertor.value_placeholders
7575
name_placeholders = convertor.name_placeholders

lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class AwsSdkV3
1313
class Scan
1414
attr_reader :client, :table, :conditions, :options
1515

16-
def initialize(client, table, conditions = {}, options = {})
16+
def initialize(client, table, conditions = [], options = {})
1717
@client = client
1818
@table = table
1919
@conditions = conditions

lib/dynamoid/criteria/chain.rb

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,27 @@ def initialize(source)
9595
#
9696
# Internally +where+ performs either +Scan+ or +Query+ operation.
9797
#
98+
# Conditions can be specified as an expression as well:
99+
#
100+
# Post.where('links_count = :v', v: 2)
101+
#
102+
# This way complex expressions can be constructed (e.g. with AND, OR, and NOT
103+
# keyword):
104+
#
105+
# Address.where('city = :c AND (post_code = :pc1 OR post_code = :pc2)', city: 'A', pc1: '001', pc2: '002')
106+
#
107+
# See documentation for condition expression's syntax and examples:
108+
# - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html
109+
# - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.FilterExpression.html
110+
#
98111
# @return [Dynamoid::Criteria::Chain]
99112
# @since 0.2.0
100-
def where(args)
101-
detector = NonexistentFieldsDetector.new(args, @source)
102-
if detector.found?
103-
Dynamoid.logger.warn(detector.warning_message)
113+
def where(conditions, placeholders = nil)
114+
if conditions.is_a?(Hash)
115+
where_with_hash(conditions)
116+
else
117+
where_with_string(conditions, placeholders)
104118
end
105-
106-
@where_conditions.update(args.symbolize_keys)
107-
108-
# we should re-initialize keys detector every time we change @where_conditions
109-
@key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: @forced_index_name)
110-
111-
self
112119
end
113120

114121
# Turns on strongly consistent reads.
@@ -500,6 +507,29 @@ def pluck(*args)
500507

501508
private
502509

510+
def where_with_hash(conditions)
511+
detector = NonexistentFieldsDetector.new(conditions, @source)
512+
if detector.found?
513+
Dynamoid.logger.warn(detector.warning_message)
514+
end
515+
516+
@where_conditions.update_with_hash(conditions.symbolize_keys)
517+
518+
# we should re-initialize keys detector every time we change @where_conditions
519+
@key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: @forced_index_name)
520+
521+
self
522+
end
523+
524+
def where_with_string(query, placeholders)
525+
@where_conditions.update_with_string(query, placeholders)
526+
527+
# we should re-initialize keys detector every time we change @where_conditions
528+
@key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: @forced_index_name)
529+
530+
self
531+
end
532+
503533
# The actual records referenced by the association.
504534
#
505535
# @return [Enumerator] an iterator of the found records.
@@ -635,12 +665,12 @@ def query_key_conditions
635665
end
636666

637667
def query_non_key_conditions
638-
opts = {}
668+
hash_conditions = {}
639669

640670
# Honor STI and :type field if it presents
641671
if @source.attributes.key?(@source.inheritance_field) &&
642672
@key_fields_detector.hash_key.to_sym != @source.inheritance_field.to_sym
643-
@where_conditions.update(sti_condition)
673+
@where_conditions.update_with_hash(sti_condition)
644674
end
645675

646676
# TODO: Separate key conditions and non-key conditions properly:
@@ -650,11 +680,17 @@ def query_non_key_conditions
650680
.reject { |k, _| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }
651681
keys.each do |key|
652682
name, condition = field_condition(key, @where_conditions[key])
653-
opts[name] ||= []
654-
opts[name] << condition
683+
hash_conditions[name] ||= []
684+
hash_conditions[name] << condition
655685
end
656686

657-
opts
687+
string_conditions = []
688+
@where_conditions.string_conditions.each do |query, placeholders|
689+
placeholders ||= {}
690+
string_conditions << [query, placeholders]
691+
end
692+
693+
[hash_conditions] + string_conditions
658694
end
659695

660696
# TODO: casting should be operator aware
@@ -721,16 +757,25 @@ def query_options
721757
def scan_conditions
722758
# Honor STI and :type field if it presents
723759
if sti_condition
724-
@where_conditions.update(sti_condition)
760+
@where_conditions.update_with_hash(sti_condition)
725761
end
726762

727-
{}.tap do |opts|
763+
hash_conditions = {}
764+
hash_conditions.tap do |opts|
728765
@where_conditions.keys.map(&:to_sym).each do |key|
729766
name, condition = field_condition(key, @where_conditions[key])
730767
opts[name] ||= []
731768
opts[name] << condition
732769
end
733770
end
771+
772+
string_conditions = []
773+
@where_conditions.string_conditions.each do |query, placeholders|
774+
placeholders ||= {}
775+
string_conditions << [query, placeholders]
776+
end
777+
778+
[hash_conditions] + string_conditions
734779
end
735780

736781
def scan_options

lib/dynamoid/criteria/where_conditions.rb

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,31 @@ module Dynamoid
44
module Criteria
55
# @private
66
class WhereConditions
7+
attr_reader :string_conditions
8+
79
def initialize
8-
@conditions = []
10+
@hash_conditions = []
11+
@string_conditions = []
12+
end
13+
14+
def update_with_hash(hash)
15+
@hash_conditions << hash.symbolize_keys
916
end
1017

11-
def update(hash)
12-
@conditions << hash.symbolize_keys
18+
def update_with_string(query, placeholders)
19+
@string_conditions << [query, placeholders]
1320
end
1421

1522
def keys
16-
@conditions.flat_map(&:keys)
23+
@hash_conditions.flat_map(&:keys)
1724
end
1825

1926
def empty?
20-
@conditions.empty?
27+
@hash_conditions.empty? && @string_conditions.empty?
2128
end
2229

2330
def [](key)
24-
hash = @conditions.find { |h| h.key?(key) }
31+
hash = @hash_conditions.find { |h| h.key?(key) }
2532
hash[key] if hash
2633
end
2734
end

0 commit comments

Comments
 (0)