Skip to content

Commit 8648087

Browse files
authored
Merge pull request #359 from Dynamoid/add-null-and-not-null-predicates
Add support of NULL and NOT_NULL operators
2 parents 8b176b7 + 23398e7 commit 8648087

File tree

8 files changed

+111
-16
lines changed

8 files changed

+111
-16
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,14 +634,21 @@ Address.where('latitude.between': [10212, 20000])
634634
```
635635

636636
You are able to filter results on the DynamoDB side and specify conditions for non-key fields.
637-
Following operators are available: `in`, `contains`, `not_contains`:
637+
Following additional operators are available: `in`, `contains`, `not_contains`, `null`, `not_null`:
638638

639639
```ruby
640640
Address.where('city.in': ['London', 'Edenburg', 'Birmingham'])
641641
Address.where('city.contains': ['on'])
642642
Address.where('city.not_contains': ['ing'])
643+
Address.where('postcode.null': false)
644+
Address.where('postcode.not_null': true)
643645
```
644646

647+
**WARNING:** Please take into accout that `NULL` and `NOT_NULL`
648+
operators check attribute presence in a document, not value.
649+
So if attribute `postcode`'s value is `NULL`, `NULL` operator will return false
650+
because attribute exists even if has `NULL` value.
651+
645652
### Consistent Reads
646653

647654
Querying supports consistent reading. By default, DynamoDB reads are eventually consistent: if you do a write and then a read immediately afterwards, the results of the previous write may not be reflected. If you need to do a consistent read (that is, you need to read the results of a write immediately) you can do so, but keep in mind that consistent reads are twice as expensive as regular reads for DynamoDB.

lib/dynamoid/adapter_plugin/aws_sdk_v3.rb

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ class AwsSdkV3
2222
range_eq: 'EQ'
2323
}.freeze
2424

25-
# Don't implement NULL and NOT_NULL because it doesn't make seanse -
26-
# we declare schema in models
2725
FIELD_MAP = {
2826
eq: 'EQ',
2927
ne: 'NE',
@@ -35,7 +33,9 @@ class AwsSdkV3
3533
between: 'BETWEEN',
3634
in: 'IN',
3735
contains: 'CONTAINS',
38-
not_contains: 'NOT_CONTAINS'
36+
not_contains: 'NOT_CONTAINS',
37+
null: 'NULL',
38+
not_null: 'NOT_NULL',
3939
}.freeze
4040
HASH_KEY = 'HASH'
4141
RANGE_KEY = 'RANGE'
@@ -356,9 +356,14 @@ def delete_item(table_name, key, options = {})
356356
# @since 1.0.0
357357
def delete_table(table_name, options = {})
358358
resp = client.delete_table(table_name: table_name)
359-
UntilPastTableStatus.new(client, table_name, :deleting).call if options[:sync] &&
360-
(status = PARSE_TABLE_STATUS.call(resp, :table_description)) &&
361-
status == TABLE_STATUSES[:deleting]
359+
360+
if options[:sync]
361+
status = PARSE_TABLE_STATUS.call(resp, :table_description)
362+
if status == TABLE_STATUSES[:deleting]
363+
UntilPastTableStatus.new(client, table_name, :deleting).call
364+
end
365+
end
366+
362367
table_cache.delete(table_name)
363368
rescue Aws::DynamoDB::Errors::ResourceInUseException => e
364369
Dynamoid.logger.error "Table #{table_name} cannot be deleted as it is in use"
@@ -619,15 +624,15 @@ def result_item_to_hash(item)
619624
# https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html
620625
# @params [String] operator: value of RANGE_MAP or FIELD_MAP hash, e.g. "EQ", "LT" etc
621626
# @params [Object] value: scalar value or array/set
622-
def attribute_value_list(operator, value)
623-
self.class.attribute_value_list(operator, value)
624-
end
625-
626627
def self.attribute_value_list(operator, value)
627628
# For BETWEEN and IN operators we should keep value as is (it should be already an array)
629+
# NULL and NOT_NULL require absence of attribute list
628630
# For all the other operators we wrap the value with array
631+
# https://docs.aws.amazon.com/en_us/amazondynamodb/latest/developerguide/LegacyConditionalParameters.Conditions.html
629632
if %w[BETWEEN IN].include?(operator)
630633
[value].flatten
634+
elsif %w[NULL NOT_NULL].include?(operator)
635+
nil
631636
else
632637
[value]
633638
end

lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,16 @@ def call
6262
end
6363
resp = client.create_table(client_opts)
6464
options[:sync] = true if !options.key?(:sync) && ls_indexes.present? || gs_indexes.present?
65-
UntilPastTableStatus.new(client, table_name, :creating).call if options[:sync] &&
66-
(status = PARSE_TABLE_STATUS.call(resp, :table_description)) &&
67-
status == TABLE_STATUSES[:creating]
65+
66+
if options[:sync]
67+
status = PARSE_TABLE_STATUS.call(resp, :table_description)
68+
if status == TABLE_STATUSES[:creating]
69+
UntilPastTableStatus.new(client, table_name, :creating).call
70+
end
71+
end
72+
6873
# Response to original create_table, which, if options[:sync]
69-
# may have an outdated table_description.table_status of "CREATING"
74+
# may have an outdated table_description.table_status of "CREATING"
7075
resp
7176
end
7277

lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ def scan_filter
7575
comparison_operator: AwsSdkV3::FIELD_MAP[cond.keys[0]],
7676
attribute_value_list: AwsSdkV3.attribute_value_list(AwsSdkV3::FIELD_MAP[cond.keys[0]], cond.values[0].freeze)
7777
}
78+
# nil means operator doesn't require attribute value list
79+
conditions.delete(:attribute_value_list) if conditions[:attribute_value_list].nil?
7880
result[attr] = condition
7981
result
8082
end

lib/dynamoid/criteria/chain.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,13 @@ def field_hash(key)
271271
{ contains: val }
272272
when 'not_contains'
273273
{ not_contains: val }
274+
# NULL/NOT_NULL operators don't have parameters
275+
# So { null: true } means NULL check and { null: false } means NOT_NULL one
276+
# The same logic is used for { not_null: BOOL }
277+
when 'null'
278+
val ? { null: nil } : { not_null: nil }
279+
when 'not_null'
280+
val ? { not_null: nil } : { null: nil }
274281
end
275282

276283
{ name.to_sym => hash }
@@ -314,10 +321,15 @@ def range_query
314321
opts.merge(query_opts).merge(consistent_opts)
315322
end
316323

324+
# TODO casting should be operator aware
325+
# e.g. for NULL operator value should be boolean
326+
# and isn't related to an attribute own type
317327
def type_cast_condition_parameter(key, value)
318328
return value if %i[array set].include?(source.attributes[key.to_sym][:type])
319329

320-
if !value.respond_to?(:to_ary)
330+
if [true, false].include?(value) # Support argument for null/not_null operators
331+
value
332+
elsif !value.respond_to?(:to_ary)
321333
options = source.attributes[key.to_sym]
322334
value_casted = TypeCasting.cast_field(value, options)
323335
Dumping.dump_field(value_casted, options)

spec/dynamoid/criteria/chain_spec.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,34 @@ def request_params
341341
expect(model.where(name: 'a', 'job_title.not_contains': 'Consul').all)
342342
.to contain_exactly(customer2)
343343
end
344+
345+
it 'supports null' do
346+
model.create_table
347+
348+
put_attributes(model.table_name, name: 'a', last_name: 'aa', age: 1)
349+
put_attributes(model.table_name, name: 'a', last_name: 'bb', age: 2)
350+
put_attributes(model.table_name, name: 'a', last_name: 'cc', )
351+
352+
documents = model.where(name: 'a', 'age.null': true).to_a
353+
expect(documents.map(&:last_name)).to contain_exactly('cc')
354+
355+
documents = model.where(name: 'a', 'age.null': false).to_a
356+
expect(documents.map(&:last_name)).to contain_exactly('aa', 'bb')
357+
end
358+
359+
it 'supports not_null' do
360+
model.create_table
361+
362+
put_attributes(model.table_name, name: 'a', last_name: 'aa', age: 1)
363+
put_attributes(model.table_name, name: 'a', last_name: 'bb', age: 2)
364+
put_attributes(model.table_name, name: 'a', last_name: 'cc', )
365+
366+
documents = model.where(name: 'a', 'age.not_null': true).to_a
367+
expect(documents.map(&:last_name)).to contain_exactly('aa', 'bb')
368+
369+
documents = model.where('age.not_null': false).to_a
370+
expect(documents.map(&:last_name)).to contain_exactly('cc')
371+
end
344372
end
345373

346374
# http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.ScanFilter.html?shortFooter=true
@@ -490,6 +518,34 @@ def request_params
490518
expect(model.where('job_title.not_contains': 'Consul').all)
491519
.to contain_exactly(customer2)
492520
end
521+
522+
it 'supports null' do
523+
model.create_table
524+
525+
put_attributes(model.table_name, id: '1', age: 1)
526+
put_attributes(model.table_name, id: '2', age: 2)
527+
put_attributes(model.table_name, id: '3')
528+
529+
documents = model.where('age.null': true).to_a
530+
expect(documents.map(&:id)).to contain_exactly('3')
531+
532+
documents = model.where('age.null': false).to_a
533+
expect(documents.map(&:id)).to contain_exactly('1', '2')
534+
end
535+
536+
it 'supports not_null' do
537+
model.create_table
538+
539+
put_attributes(model.table_name, id: '1', age: 1)
540+
put_attributes(model.table_name, id: '2', age: 2)
541+
put_attributes(model.table_name, id: '3')
542+
543+
documents = model.where('age.not_null': true).to_a
544+
expect(documents.map(&:id)).to contain_exactly('1', '2')
545+
546+
documents = model.where('age.not_null': false).to_a
547+
expect(documents.map(&:id)).to contain_exactly('3')
548+
end
493549
end
494550

495551
describe 'Lazy loading' do

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,6 @@
4747
config.include NewClassHelper
4848
config.include DumpingHelper
4949
config.include PersistenceHelper
50+
config.include ChainHelper
5051
config.include ActiveSupport::Testing::TimeHelpers
5152
end

spec/support/chain_helper.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
module ChainHelper
4+
def put_attributes(table_name, attributes)
5+
Dynamoid.adapter.put_item(table_name, attributes)
6+
end
7+
end

0 commit comments

Comments
 (0)