Skip to content

Commit f51d189

Browse files
authored
Merge pull request #354 from Dynamoid/add-warning-about-skipped-conditions
Add :map field type
2 parents eaeb37c + d42db89 commit f51d189

File tree

6 files changed

+279
-53
lines changed

6 files changed

+279
-53
lines changed

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ These fields will not change an existing table: so specifying a new read_capacit
121121
You'll have to define all the fields on the model and the data type of each field. Every field on the object must be included here; if you miss any they'll be completely bypassed during DynamoDB's initialization and will not appear on the model objects.
122122

123123
By default, fields are assumed to be of type `:string`. Other built-in types are
124-
`:integer`, `:number`, `:set`, `:array`, `:datetime`, `date`, `:boolean`, `:raw` and `:serialized`.
124+
`:integer`, `:number`, `:set`, `:array`, `:map`, `:datetime`, `date`, `:boolean`, `:raw` and `:serialized`.
125+
`array` and `map` match List and Map DynamoDB types respectively.
125126
`raw` type means you can store Ruby Array, Hash, String and numbers.
126127
If built-in types do not suit you, you can use a custom field type represented by an arbitrary class, provided that the class supports a compatible serialization interface.
127128
The primary use case for using a custom field type is to represent your business logic with high-level types, while ensuring portability or backward-compatibility of the serialized representation.
@@ -531,6 +532,18 @@ u.addresses.where(city: 'Chicago').all
531532

532533
But keep in mind Dynamoid -- and document-based storage systems in general -- are not drop-in replacements for existing relational databases. The above query does not efficiently perform a conditional join, but instead finds all the user's addresses and naively filters them in Ruby. For large associations this is a performance hit compared to relational database engines.
533534

535+
**WARNING:** There is a limitation of conditions passed to `where`
536+
method. Only one condition for some particular field could be specified.
537+
The last one only will be applyed and others will be ignored. E.g. in
538+
examples:
539+
540+
```ruby
541+
User.where('age.gt': 10, 'age.lt': 20)
542+
User.where(name: 'Mike').where('name.begins_with': 'Ed')
543+
```
544+
545+
the first one will be ignored and the last one will be used.
546+
534547
#### Limits
535548

536549
There are three types of limits that you can query with:
@@ -901,6 +914,32 @@ Dynamoid.configure do |config|
901914
end
902915
```
903916

917+
## Logging
918+
919+
There is a config option `logger`. Dynamoid writes requests and
920+
responses to DynamoDB using this logger on the `debug` level. So in
921+
order to troubleshoot and debug issues just set it:
922+
923+
```ruby
924+
class User
925+
include Dynamoid::Document
926+
field name
927+
end
928+
929+
Dynamoid.config.logger.level = :debug
930+
Dynamoid.config.endpoint = 'localhost:8000'
931+
932+
User.create(name: 'Alex')
933+
934+
# => D, [2019-05-12T20:01:07.840051 #75059] DEBUG -- : put_item | Request "{\"TableName\":\"dynamoid_users\",\"Item\":{\"created_at\":{\"N\":\"1557680467.608749\"},\"updated_at\":{\"N\":\"1557680467.608809\"},\"id\":{\"S\":\"1227eea7-2c96-4b8a-90d9-77b38eb85cd0\"}},\"Expected\":{\"id\":{\"Exists\":false}}}" | Response "{}"
935+
936+
# => D, [2019-05-12T20:01:07.842397 #75059] DEBUG -- : (231.28 ms) PUT ITEM - ["dynamoid_users", {:created_at=>0.1557680467608749e10, :updated_at=>0.1557680467608809e10, :id=>"1227eea7-2c96-4b8a-90d9-77b38eb85cd0", :User=>nil}, {}]
937+
```
938+
939+
The first line is a body of HTTP request and response. The second line -
940+
Dynamoid internal logging of API call (`PUT ITEM` in our case) with
941+
timing (231.28 ms).
942+
904943
## Credits
905944

906945
Dynamoid borrows code, structure, and even its name very liberally from the truly amazing [Mongoid](https://github.com/mongoid/mongoid). Without Mongoid to crib from none of this would have been possible, and I hope they don't mind me reusing their very awesome ideas to make DynamoDB just as accessible to the Ruby world as MongoDB.

lib/dynamoid/dumping.rb

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def self.find_dumper(options)
2929
when :number then NumberDumper
3030
when :set then SetDumper
3131
when :array then ArrayDumper
32+
when :map then MapDumper
3233
when :datetime then DateTimeDumper
3334
when :date then DateDumper
3435
when :serialized then SerializedDumper
@@ -42,6 +43,35 @@ def self.find_dumper(options)
4243
end
4344
end
4445

46+
module DeepSanitizeHelper
47+
extend self
48+
49+
def deep_sanitize(value)
50+
case value
51+
when Hash
52+
sanitize_hash(value).transform_values { |v| deep_sanitize(v) }
53+
when Array
54+
sanitize_array(value).map { |v| deep_sanitize(v) }
55+
else
56+
value
57+
end
58+
end
59+
60+
private
61+
62+
def sanitize_hash(hash)
63+
hash.transform_values { |v| invalid_value?(v) ? nil : v }
64+
end
65+
66+
def sanitize_array(array)
67+
array.map { |v| invalid_value?(v) ? nil : v }
68+
end
69+
70+
def invalid_value?(value)
71+
(value.is_a?(Set) || value.is_a?(String)) && value.empty?
72+
end
73+
end
74+
4575
class Base
4676
def initialize(options)
4777
@options = options
@@ -168,6 +198,13 @@ def element_options
168198
end
169199
end
170200

201+
# hash -> map
202+
class MapDumper < Base
203+
def process(value)
204+
DeepSanitizeHelper.deep_sanitize(value)
205+
end
206+
end
207+
171208
# datetime -> integer/string
172209
class DateTimeDumper < Base
173210
def process(value)
@@ -221,32 +258,7 @@ def format_date(value, options)
221258
# any standard Ruby object -> self
222259
class RawDumper < Base
223260
def process(value)
224-
deep_sanitize(value)
225-
end
226-
227-
private
228-
229-
def deep_sanitize(value)
230-
case value
231-
when Hash
232-
sanitize_hash(value).transform_values { |v| deep_sanitize(v) }
233-
when Array
234-
sanitize_array(value).map { |v| deep_sanitize(v) }
235-
else
236-
value
237-
end
238-
end
239-
240-
def sanitize_hash(hash)
241-
hash.transform_values { |v| invalid_value?(v) ? nil : v }
242-
end
243-
244-
def sanitize_array(array)
245-
array.map { |v| invalid_value?(v) ? nil : v }
246-
end
247-
248-
def invalid_value?(value)
249-
(value.is_a?(Set) || value.is_a?(String)) && value.empty?
261+
DeepSanitizeHelper.deep_sanitize(value)
250262
end
251263
end
252264

lib/dynamoid/type_casting.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def self.find_type_caster(options)
2929
when :number then NumberTypeCaster
3030
when :set then SetTypeCaster
3131
when :array then ArrayTypeCaster
32+
when :map then MapTypeCaster
3233
when :datetime then DateTimeTypeCaster
3334
when :date then DateTypeCaster
3435
when :raw then RawTypeCaster
@@ -204,6 +205,22 @@ def element_options
204205
end
205206
end
206207

208+
class MapTypeCaster < Base
209+
def process(value)
210+
return nil if value.nil?
211+
212+
if value.is_a? Hash
213+
value
214+
elsif value.respond_to? :to_hash
215+
value.to_hash
216+
elsif value.respond_to? :to_h
217+
value.to_h
218+
else
219+
nil
220+
end
221+
end
222+
end
223+
207224
class DateTimeTypeCaster < Base
208225
def process(value)
209226
if !value.respond_to?(:to_datetime)

lib/dynamoid/undumping.rb

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def self.find_undumper(options)
3232
when :number then NumberUndumper
3333
when :set then SetUndumper
3434
when :array then ArrayUndumper
35+
when :map then MapUndumper
3536
when :datetime then DateTimeUndumper
3637
when :date then DateUndumper
3738
when :raw then RawUndumper
@@ -45,6 +46,35 @@ def self.find_undumper(options)
4546
end
4647
end
4748

49+
module UndumpHashHelper
50+
extend self
51+
52+
def undump_hash(hash)
53+
{}.tap do |h|
54+
hash.each { |key, value| h[key.to_sym] = undump_hash_value(value) }
55+
end
56+
end
57+
58+
private
59+
60+
def undump_hash_value(val)
61+
case val
62+
when BigDecimal
63+
if Dynamoid::Config.convert_big_decimal
64+
val.to_f
65+
else
66+
val
67+
end
68+
when Hash
69+
undump_hash(val)
70+
when Array
71+
val.map { |v| undump_hash_value(v) }
72+
else
73+
val
74+
end
75+
end
76+
end
77+
4878
class Base
4979
def initialize(options)
5080
@options = options
@@ -157,6 +187,12 @@ def element_options
157187
end
158188
end
159189

190+
class MapUndumper < Base
191+
def process(value)
192+
UndumpHashHelper.undump_hash(value)
193+
end
194+
end
195+
160196
class DateTimeUndumper < Base
161197
def process(value)
162198
return value if value.is_a?(Date) || value.is_a?(DateTime) || value.is_a?(Time)
@@ -190,36 +226,11 @@ def process(value)
190226
class RawUndumper < Base
191227
def process(value)
192228
if value.is_a?(Hash)
193-
undump_hash(value)
229+
UndumpHashHelper.undump_hash(value)
194230
else
195231
value
196232
end
197233
end
198-
199-
private
200-
201-
def undump_hash(hash)
202-
{}.tap do |h|
203-
hash.each { |key, value| h[key.to_sym] = undump_hash_value(value) }
204-
end
205-
end
206-
207-
def undump_hash_value(val)
208-
case val
209-
when BigDecimal
210-
if Dynamoid::Config.convert_big_decimal
211-
val.to_f
212-
else
213-
val
214-
end
215-
when Hash
216-
undump_hash(val)
217-
when Array
218-
val.map { |v| undump_hash_value(v) }
219-
else
220-
val
221-
end
222-
end
223234
end
224235

225236
class SerializedUndumper < Base

spec/dynamoid/dumping_spec.rb

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,106 @@ def self.dynamoid_load(string)
10031003
end
10041004
end
10051005

1006+
describe 'Map field' do
1007+
let(:klass) do
1008+
new_class do
1009+
field :settings, :map
1010+
end
1011+
end
1012+
1013+
it 'stores as a Document' do
1014+
settings = {
1015+
Day: "Monday",
1016+
UnreadEmails: 42,
1017+
ItemsOnMyDesk: [
1018+
"Coffee Cup",
1019+
"Telephone",
1020+
{
1021+
Pens: { Quantity: 3 },
1022+
Pencils: { Quantity: 2 },
1023+
Erasers: { Quantity: 1 }
1024+
}
1025+
]
1026+
}
1027+
obj = klass.create(settings: settings)
1028+
1029+
expect(reload(obj).settings).to eql(
1030+
{
1031+
Day: "Monday",
1032+
UnreadEmails: 42,
1033+
ItemsOnMyDesk: [
1034+
"Coffee Cup",
1035+
"Telephone",
1036+
{
1037+
Pens: { Quantity: 3 },
1038+
Pencils: { Quantity: 2 },
1039+
Erasers: { Quantity: 1 }
1040+
}
1041+
]
1042+
}
1043+
)
1044+
expect(raw_attributes(obj)[:settings]).to eql(
1045+
{
1046+
"Day" => "Monday",
1047+
"UnreadEmails" => 42,
1048+
"ItemsOnMyDesk" => [
1049+
"Coffee Cup",
1050+
"Telephone",
1051+
{
1052+
"Pens" => { "Quantity" => 3 },
1053+
"Pencils" => { "Quantity" => 2 },
1054+
"Erasers" => { "Quantity" => 1 }
1055+
}
1056+
]
1057+
}
1058+
)
1059+
end
1060+
1061+
it 'deeply symbolizes keys' do
1062+
settings = { 'foo' => { 'bar' => 1 }, 'baz' => [{ 'foobar' => 2 }] }
1063+
obj = klass.create(settings: settings)
1064+
1065+
expect(reload(obj).settings).to eql(foo: { bar: 1 }, baz: [{ foobar: 2 }])
1066+
end
1067+
1068+
describe 'sanitizing' do
1069+
it 'replaces empty set with nil in Hash' do
1070+
settings = { 'foo' => [].to_set }
1071+
obj = klass.create(settings: settings)
1072+
1073+
expect(reload(obj).settings).to eql(foo: nil)
1074+
end
1075+
1076+
it 'replaces empty string with nil in Hash' do
1077+
settings = { 'foo' => '' }
1078+
obj = klass.create(settings: settings)
1079+
1080+
expect(reload(obj).settings).to eql(foo: nil)
1081+
end
1082+
1083+
it 'replaces empty set with nil in nested Array' do
1084+
settings = { 'foo' => [1, 2, [].to_set] }
1085+
obj = klass.create(settings: settings)
1086+
1087+
expect(reload(obj).settings).to eql(foo: [1, 2, nil])
1088+
end
1089+
1090+
it 'replaces empty string with nil in nested Array' do
1091+
settings = { 'foo' => [1, 2, ''] }
1092+
obj = klass.create(settings: settings)
1093+
1094+
expect(reload(obj).settings).to eql(foo: [1, 2, nil])
1095+
end
1096+
1097+
it 'processes nested Hash and Array' do
1098+
settings = { a: 1, b: '', c: [1, 2, '', { d: 3, e: '' }] }
1099+
obj = klass.create(settings: settings)
1100+
1101+
expect(reload(obj).settings).to eql(a: 1, b: nil, c: [1, 2, nil, { d: 3, e: nil }])
1102+
end
1103+
end
1104+
end
1105+
10061106
describe 'String field' do
10071107
it 'stores as strings' do
10081108
klass = new_class do

0 commit comments

Comments
 (0)