Skip to content

Commit a16be4e

Browse files
authored
Merge pull request #851 from Dynamoid/ak/don-require-custom-type-to-implement-equality-method
Fix Dirty API to compare objects of custom type using dumps
2 parents 4902a5c + 9604f25 commit a16be4e

File tree

5 files changed

+118
-28
lines changed

5 files changed

+118
-28
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,27 @@ method, which would return either `:string` or `:number`.
471471
DynamoDB may support some other attribute types that are not yet
472472
supported by Dynamoid.
473473

474+
If a custom type implements `#==` method you can specify `comparable:
475+
true` option in a field declaration to specify that an object is safely
476+
comparable for the purpose of detecting changes. By default old and new
477+
objects will be compared by their serialized representation.
478+
479+
```ruby
480+
class Money
481+
# ...
482+
483+
def ==(other)
484+
# comparison logic
485+
end
486+
end
487+
488+
class User
489+
# ...
490+
491+
field :balance, Money, comparable: true
492+
end
493+
```
494+
474495
### Sort key
475496

476497
Along with partition key table may have a sort key. In order to declare

lib/dynamoid/dirty.rb

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,19 @@ def attribute_changed_in_place?(name)
301301
return false if value_from_database.nil?
302302

303303
value = read_attribute(name)
304-
value != value_from_database
304+
type_options = self.class.attributes[name.to_sym]
305+
306+
unless type_options[:type].is_a?(Class) && !type_options[:comparable]
307+
# common case
308+
value != value_from_database
309+
else
310+
# objects of a custom type that does not implement its own `#==` method
311+
# (that's declared by `comparable: false` or just not specifying the
312+
# option `comparable`) are compared by comparing their dumps
313+
dump = Dumping.dump_field(value, type_options)
314+
dump_from_database = Dumping.dump_field(value_from_database, type_options)
315+
dump != dump_from_database
316+
end
305317
end
306318

307319
module DeepDupper
@@ -318,26 +330,25 @@ def self.dup_attribute(value, type_options)
318330

319331
case value
320332
when NilClass, TrueClass, FalseClass, Numeric, Symbol, IO
321-
# till Ruby 2.4 these immutable objects could not be duplicated
322-
# IO objects cannot be duplicated - is used for binary fields
333+
# Till Ruby 2.4 these immutable objects could not be duplicated.
334+
# IO objects (used for the binary type) cannot be duplicated as well.
323335
value
324-
when String
325-
value.dup
326336
when Array
327-
if of.is_a? Class # custom type
337+
if of.is_a? Class
338+
# custom type
328339
value.map { |e| dup_attribute(e, type: of) }
329340
else
330341
value.deep_dup
331342
end
332343
when Set
333344
Set.new(value.map { |e| dup_attribute(e, type: of) })
334-
when Hash
335-
value.deep_dup
336345
else
337-
if type.is_a? Class # custom type
338-
Marshal.load(Marshal.dump(value)) # dup instance variables
346+
if type.is_a? Class
347+
# custom type
348+
dump = Dumping.dump_field(value, type_options)
349+
Undumping.undump_field(dump.deep_dup, type_options)
339350
else
340-
value.dup # date, datetime
351+
value.deep_dup
341352
end
342353
end
343354
end

spec/dynamoid/dirty_spec.rb

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@
646646

647647
let(:klass_with_set_of_custom_type) do
648648
new_class do
649-
field :users, :set, of: DirtySpec::User
649+
field :users, :set, of: DirtySpec::UserWithEquality
650650
end
651651
end
652652

@@ -686,6 +686,12 @@
686686
end
687687
end
688688

689+
let(:klass_with_comparable_custom_type) do
690+
new_class do
691+
field :user, DirtySpec::UserWithEquality, comparable: true
692+
end
693+
end
694+
689695
context 'string type' do
690696
it 'detects in-place modifying a String value' do
691697
obj = klass_with_string.create!(name: +'Alex')
@@ -711,10 +717,15 @@
711717
end
712718

713719
it 'detects in-place modifying of a Set element' do
714-
obj = klass_with_set_of_custom_type.create!(users: [DirtySpec::User.new(+'Alex')])
720+
obj = klass_with_set_of_custom_type.create!(users: [DirtySpec::UserWithEquality.new(+'Alex')])
715721
obj.users.map { |u| u.name.upcase! }
716722

717-
expect(obj.changes).to eq('users' => [Set[DirtySpec::User.new('Alex')], Set[DirtySpec::User.new('ALEX')]])
723+
expect(obj.changes).to eq(
724+
'users' => [
725+
Set[DirtySpec::UserWithEquality.new('Alex')],
726+
Set[DirtySpec::UserWithEquality.new('ALEX')]
727+
]
728+
)
718729
end
719730
end
720731

@@ -792,11 +803,56 @@
792803
end
793804

794805
context 'custom type' do
795-
it 'detects in-place modifying a String value' do
806+
it 'detects in-place modifying' do
796807
obj = klass_with_custom_type.create!(user: DirtySpec::User.new(+'Alex'))
797808
obj.user.name.upcase!
809+
ScratchPad.record []
810+
811+
old_value, new_value = obj.changes['user']
812+
813+
expect(old_value.name).to eq 'Alex'
814+
expect(new_value.name).to eq 'ALEX'
815+
expect(ScratchPad.recorded).to eq([])
816+
end
817+
818+
it 'detects in-place modifying when custom type is safely comparable' do
819+
obj = klass_with_comparable_custom_type.create!(user: DirtySpec::UserWithEquality.new(+'Alex'))
820+
obj.user.name.upcase!
821+
ScratchPad.record []
822+
823+
old_value, new_value = obj.changes['user']
824+
825+
expect(old_value.name).to eq 'Alex'
826+
expect(new_value.name).to eq 'ALEX'
827+
828+
expect(ScratchPad.recorded.size).to eq(1)
829+
record = ScratchPad.recorded[0]
830+
831+
expect(record[0]).to eq('==')
832+
expect(record[1]).to equal(new_value)
833+
expect(record[2]).to equal(old_value)
834+
end
835+
836+
it 'reports no in-place changes when field is not modified' do
837+
obj = klass_with_custom_type.create!(user: DirtySpec::User.new('Alex'))
838+
839+
ScratchPad.record []
840+
expect(obj.changes['user']).to eq(nil)
841+
expect(ScratchPad.recorded).to eq([])
842+
end
843+
844+
it 'reports no in-place changes when field is not modified and custom type is safely comparable' do
845+
obj = klass_with_comparable_custom_type.create!(user: DirtySpec::UserWithEquality.new('Alex'))
846+
ScratchPad.record []
847+
848+
expect(obj.changes['user']).to eq(nil)
849+
850+
expect(ScratchPad.recorded.size).to eq(1)
851+
record = ScratchPad.recorded[0]
798852

799-
expect(obj.changes).to eq('user' => [DirtySpec::User.new('Alex'), DirtySpec::User.new('ALEX')])
853+
expect(record[0]).to eq('==')
854+
expect(record[1]).to equal(obj.user)
855+
expect(record[2]).to eq(obj.user) # an implicit 'from-database' copy
800856
end
801857
end
802858
end

spec/fixtures/dirty.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,30 @@ def dynamoid_dump
1212
@name
1313
end
1414

15+
def self.dynamoid_load(string)
16+
new(string.to_s)
17+
end
18+
end
19+
20+
class UserWithEquality < User
1521
def eql?(other)
22+
if ScratchPad.recorded.is_a? Array
23+
ScratchPad << ['eql?', self, other]
24+
end
25+
1626
@name == other.name
1727
end
1828

1929
def ==(other)
30+
if ScratchPad.recorded.is_a? Array
31+
ScratchPad << ['==', self, other]
32+
end
33+
2034
@name == other.name
2135
end
2236

2337
def hash
2438
@name.hash
2539
end
26-
27-
def self.dynamoid_load(string)
28-
new(string.to_s)
29-
end
3040
end
3141
end

spec/fixtures/dumping.rb

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,6 @@ def dynamoid_dump
3737
"#{name} (dumped with #dynamoid_dump)"
3838
end
3939

40-
def eql?(other)
41-
name == other.name
42-
end
43-
44-
def hash
45-
name.hash
46-
end
47-
4840
def self.dynamoid_dump(user)
4941
"#{user.name} (dumped with .dynamoid_dump)"
5042
end

0 commit comments

Comments
 (0)