diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6eeb68..47470803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,49 @@ Unreleased Changes ------------------ +* Feature - Aws::Record::DirtyTracking - Improves dirty tracking by adding + support for tracking mutations of attribute value objects. This feature is on + by default for the "collection" types: `:list_attr`, `:map_attr`, + `:string_set_attr`, and `:numeric_set_attr`. + + Before this feature, the `#save` method's default behavior of running an + update call for dirty attributes only could cause problems for users of + collection attributes. As many of them are commonly manipulated using mutable + state, the underlying "clean" version of the objects would be modified and the + updated object would not be recognized as dirty, and therefore would not be + updated at all unless explicitly marked as dirty or through a force put. + + ```ruby + class Model + include Aws::Record + string_attr :uuid, hash_key: true + list_attr :collection + end + + item = Model.new(uuid: SecureRandom.uuid, collection: [1,2,3]) + item.clean! # As if loaded from the database, to demonstrate the new tracking. + item.dirty? # => false + item.collection << 4 # In place mutation of the "collection" array. + item.dirty? # => true (Previous versions would not recognize this as dirty. + item.save # Would call Aws::DynamoDB::Client#update_item for :collection only. + ``` + + Note that this feature is implemented using deep copies of collection objects + in memory, so there is a potential memory/performance hit in exchange for the + added accuracy. As such, mutation tracking can be explicitly turned off at the + attribute level or at the full model level, if desired. + + ```ruby + # Note that the disabling of mutation tracking is redundant in this example, + # for illustration purposes. + class Model + include Aws::Record + disable_mutation_tracking # For turning off mutation at the model level. + string_attr :uuid, hash_key: true + list_attr :collection, mutation_tracking: false # Turn off at attr level. + end + ``` + 1.0.0.pre.8 (2016-05-19) ------------------ diff --git a/lib/aws-record/record.rb b/lib/aws-record/record.rb index 20953e3d..4e413e11 100644 --- a/lib/aws-record/record.rb +++ b/lib/aws-record/record.rb @@ -50,6 +50,7 @@ module Record # # Attribute definitions go here... # end def self.included(sub_class) + @track_mutations = true sub_class.send(:extend, RecordClassMethods) sub_class.send(:include, Attributes) sub_class.send(:include, ItemOperations) @@ -183,6 +184,26 @@ def dynamodb_client @dynamodb_client ||= configure_client end + # Turns off mutation tracking for all attributes in the model. + def disable_mutation_tracking + @track_mutations = false + end + + # Turns on mutation tracking for all attributes in the model. Note that + # mutation tracking is on by default, so you generally would not need to + # call this. It is provided in case there is a need to dynamically turn + # this feature on and off, though that would be generally discouraged and + # could cause inaccurate mutation tracking at runtime. + def enable_mutation_tracking + @track_mutations = true + end + + # @return [Boolean] true if mutation tracking is enabled at the model + # level, false otherwise. + def mutation_tracking_enabled? + @track_mutations == false ? false : true + end + private def _user_agent(custom) if custom diff --git a/lib/aws-record/record/attribute.rb b/lib/aws-record/record/attribute.rb index 883008d5..6d60b495 100644 --- a/lib/aws-record/record/attribute.rb +++ b/lib/aws-record/record/attribute.rb @@ -39,11 +39,17 @@ class Attribute # "M", "L". Optional if this attribute will never be used for a key or # secondary index, but most convenience methods for setting attributes # will provide this. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def initialize(name, options = {}) @name = name @database_name = options[:database_attribute_name] || name.to_s @dynamodb_type = options[:dynamodb_type] @marshaler = options[:marshaler] || DefaultMarshaler + @mutation_tracking = options[:mutation_tracking] end # Attempts to type cast a raw value into the attribute's type. This call @@ -65,6 +71,12 @@ def serialize(raw_value) @marshaler.serialize(raw_value) end + # @return [Boolean] true if this attribute should do active mutation + # tracking, false otherwise. + def track_mutations? + @mutation_tracking ? true : false + end + # @api private def extract(dynamodb_item) dynamodb_item[@database_name] diff --git a/lib/aws-record/record/attributes.rb b/lib/aws-record/record/attributes.rb index 475befc2..b6367489 100644 --- a/lib/aws-record/record/attributes.rb +++ b/lib/aws-record/record/attributes.rb @@ -87,6 +87,11 @@ module ClassMethods # "M", "L". Optional if this attribute will never be used for a key or # secondary index, but most convenience methods for setting attributes # will provide this. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. # @option opts [Boolean] :hash_key Set to true if this attribute is # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is @@ -118,6 +123,11 @@ def attr(name, marshaler, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def string_attr(name, opts = {}) opts[:dynamodb_type] = "S" attr(name, Attributes::StringMarshaler, opts) @@ -132,6 +142,11 @@ def string_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def boolean_attr(name, opts = {}) opts[:dynamodb_type] = "BOOL" attr(name, Attributes::BooleanMarshaler, opts) @@ -146,6 +161,11 @@ def boolean_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def integer_attr(name, opts = {}) opts[:dynamodb_type] = "N" attr(name, Attributes::IntegerMarshaler, opts) @@ -160,6 +180,11 @@ def integer_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def float_attr(name, opts = {}) opts[:dynamodb_type] = "N" attr(name, Attributes::FloatMarshaler, opts) @@ -174,6 +199,11 @@ def float_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def date_attr(name, opts = {}) opts[:dynamodb_type] = "S" attr(name, Attributes::DateMarshaler, opts) @@ -188,6 +218,11 @@ def date_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is false. def datetime_attr(name, opts = {}) opts[:dynamodb_type] = "S" attr(name, Attributes::DateTimeMarshaler, opts) @@ -223,8 +258,14 @@ def datetime_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is true. def list_attr(name, opts = {}) opts[:dynamodb_type] = "L" + opts[:mutation_tracking] = true if opts[:mutation_tracking].nil? attr(name, Attributes::ListMarshaler, opts) end @@ -258,8 +299,14 @@ def list_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is true. def map_attr(name, opts = {}) opts[:dynamodb_type] = "M" + opts[:mutation_tracking] = true if opts[:mutation_tracking].nil? attr(name, Attributes::MapMarshaler, opts) end @@ -280,8 +327,14 @@ def map_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is true. def string_set_attr(name, opts = {}) opts[:dynamodb_type] = "SS" + opts[:mutation_tracking] = true if opts[:mutation_tracking].nil? attr(name, Attributes::StringSetMarshaler, opts) end @@ -302,8 +355,14 @@ def string_set_attr(name, opts = {}) # the hash key for the table. # @option opts [Boolean] :range_key Set to true if this attribute is # the range key for the table. + # @option options [Boolean] :mutation_tracking Optional attribute used to + # indicate if mutations to values should be explicitly tracked when + # determining if a value is "dirty". Important for collection types + # which are often primarily modified by mutation of a single object + # reference. By default, is true. def numeric_set_attr(name, opts = {}) opts[:dynamodb_type] = "NS" + opts[:mutation_tracking] = true if opts[:mutation_tracking].nil? attr(name, Attributes::NumericSetMarshaler, opts) end @@ -333,6 +392,13 @@ def keys @keys end + # @param [Symbol] attr_sym The symbolized name of the attribute. + # @return [Boolean] true if object mutations are tracked for dirty + # checking of that attribute, false if mutations are not tracked. + def track_mutations?(attr_sym) + mutation_tracking_enabled? && attributes[attr_sym].track_mutations? + end + private def define_attr_methods(name, attribute) define_method(name) do diff --git a/lib/aws-record/record/dirty_tracking.rb b/lib/aws-record/record/dirty_tracking.rb index 6366b961..ca98228b 100644 --- a/lib/aws-record/record/dirty_tracking.rb +++ b/lib/aws-record/record/dirty_tracking.rb @@ -24,6 +24,7 @@ def self.included(sub_class) # @override initialize(*) def initialize(*) @dirty_data = {} + @mutation_copies = {} super end @@ -44,7 +45,7 @@ def initialize(*) # @param [String, Symbol] name The name of the attribute to to check for dirty changes. # @return [Boolean] +true+ if the specified attribute has any dirty changes, +false+ otherwise. def attribute_dirty?(name) - @dirty_data.has_key?(name) + @dirty_data.has_key?(name) || _mutated?(name) end # Returns the original value of the specified attribute. @@ -63,7 +64,11 @@ def attribute_dirty?(name) # @param [String, Symbol] name The name of the attribute to retrieve the original value of. # @return [Object] The original value of the specified attribute. def attribute_was(name) - attribute_dirty?(name) ? @dirty_data[name] : @data[name] + if @mutation_copies.has_key?(name) + @mutation_copies[name] + else + attribute_dirty?(name) ? @dirty_data[name] : @data[name] + end end # Mark that an attribute is changing. This is useful in situations where it is necessary to track that the value of an @@ -107,7 +112,7 @@ def attribute_dirty!(name) @dirty_data[name] = begin - current_value.clone + _deep_copy(current_value) rescue TypeError current_value end @@ -132,6 +137,13 @@ def attribute_dirty!(name) # def clean! @dirty_data.clear + self.class.attributes.each do |name, attribute| + if self.class.track_mutations?(name) + if @data[name] + @mutation_copies[name] = _deep_copy(@data[name]) + end + end + end end # Returns an array with the name of the attributes with dirty changes. @@ -150,7 +162,13 @@ def clean! # # @return [Array] The names of attributes with dirty changes. def dirty - @dirty_data.keys + ret = @dirty_data.keys.dup + @mutation_copies.each do |key, value| + if @data[key] != value + ret << key unless ret.include?(key) + end + end + ret end # Returns +true+ if any attributes have dirty changes, +false+ otherwise. @@ -170,7 +188,10 @@ def dirty # @return [Boolean] +true+ if any attributes have dirty changes, +false+ # otherwise. def dirty? - @dirty_data.size > 0 + return true if @dirty_data.size > 0 + @mutation_copies.any? do |name, value| + @mutation_copies[name] != @data[name] + end end # Fetches attributes for this instance of an item from Amazon DynamoDB @@ -188,7 +209,7 @@ def reload! record = self.class.find(primary_key) - unless record.nil? + unless record.nil? @data = record.instance_variable_get("@data") else raise Errors::NotFound.new("No record found") @@ -217,7 +238,12 @@ def reload! def rollback_attribute!(name) return unless attribute_dirty?(name) - @data[name] = @dirty_data.delete(name) + if @mutation_copies.has_key?(name) + @data[name] = @mutation_copies[name] + @dirty_data.delete(name) if @dirty_data.has_key?(name) + else + @data[name] = @dirty_data.delete(name) + end end # Restores all attributes to their original values. @@ -252,15 +278,29 @@ def save(*) # # @override write_attribute(*) def write_attribute(name, attribute, value) + _dirty_write_attribute(name, attribute, value) + super + end + + def _dirty_write_attribute(name, attribute, value) if value == attribute_was(name) @dirty_data.delete(name) else attribute_dirty!(name) end + end - super + def _mutated?(name) + if @mutation_copies.has_key?(name) + @data[name] != @mutation_copies[name] + else + false + end end + def _deep_copy(obj) + Marshal.load(Marshal.dump(obj)) + end module DirtyTrackingClassMethods diff --git a/lib/aws-record/record/item_operations.rb b/lib/aws-record/record/item_operations.rb index ca3e2562..ddffdd1f 100644 --- a/lib/aws-record/record/item_operations.rb +++ b/lib/aws-record/record/item_operations.rb @@ -21,17 +21,17 @@ def self.included(sub_class) end # Saves this instance of an item to Amazon DynamoDB. If this item is "new" - # as defined by having new or altered key attributes, will attempt a - # conditional - # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#put_item-instance_method Aws::DynamoDB::Client#put_item} - # call, which will not overwrite an existing item. If the item only has - # altered non-key attributes, will perform an - # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method Aws::DynamoDB::Client#update_item} - # call. Uses this item instance's attributes in order to build the - # request on your behalf. + # as defined by having new or altered key attributes, will attempt a + # conditional + # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#put_item-instance_method Aws::DynamoDB::Client#put_item} + # call, which will not overwrite an existing item. If the item only has + # altered non-key attributes, will perform an + # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method Aws::DynamoDB::Client#update_item} + # call. Uses this item instance's attributes in order to build the + # request on your behalf. # # You can use the +:force+ option to perform a simple put/overwrite - # without conditional validation or update logic. + # without conditional validation or update logic. # # @param [Hash] opts # @option opts [Boolean] :force if true, will save as a put operation and @@ -54,17 +54,17 @@ def save!(opts = {}) end # Saves this instance of an item to Amazon DynamoDB. If this item is "new" - # as defined by having new or altered key attributes, will attempt a - # conditional - # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#put_item-instance_method Aws::DynamoDB::Client#put_item} - # call, which will not overwrite an existing item. If the item only has - # altered non-key attributes, will perform an - # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method Aws::DynamoDB::Client#update_item} - # call. Uses this item instance's attributes in order to build the - # request on your behalf. + # as defined by having new or altered key attributes, will attempt a + # conditional + # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#put_item-instance_method Aws::DynamoDB::Client#put_item} + # call, which will not overwrite an existing item. If the item only has + # altered non-key attributes, will perform an + # {http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method Aws::DynamoDB::Client#update_item} + # call. Uses this item instance's attributes in order to build the + # request on your behalf. # # You can use the +:force+ option to perform a simple put/overwrite - # without conditional validation or update logic. + # without conditional validation or update logic. # # @param [Hash] opts # @option opts [Boolean] :force if true, will save as a put operation and diff --git a/spec/aws-record/record/attribute_spec.rb b/spec/aws-record/record/attribute_spec.rb index 20b13ce2..44dce50f 100644 --- a/spec/aws-record/record/attribute_spec.rb +++ b/spec/aws-record/record/attribute_spec.rb @@ -17,10 +17,30 @@ module Aws module Record describe Attribute do - it 'can have a custom DB name' do - a = Attribute.new(:foo, database_attribute_name: "bar") - expect(a.name).to eq(:foo) - expect(a.database_name).to eq("bar") + context 'database_attribute_name' do + it 'can have a custom DB name' do + a = Attribute.new(:foo, database_attribute_name: "bar") + expect(a.name).to eq(:foo) + expect(a.database_name).to eq("bar") + end + + it 'uses the attribute name by default for the DB name' do + a = Attribute.new(:foo) + expect(a.name).to eq(:foo) + expect(a.database_name).to eq("foo") + end + end + + context 'mutation_tracking' do + it 'does not track mutations by default' do + a = Attribute.new(:foo) + expect(a.track_mutations?).to eq(false) + end + + it 'has an option to track mutations' do + a = Attribute.new(:foo, mutation_tracking: true) + expect(a.track_mutations?).to eq(true) + end end end diff --git a/spec/aws-record/record/attributes_spec.rb b/spec/aws-record/record/attributes_spec.rb index 264c7fb3..4de8f95d 100644 --- a/spec/aws-record/record/attributes_spec.rb +++ b/spec/aws-record/record/attributes_spec.rb @@ -152,6 +152,33 @@ module Record end end + context "Mutation Tracking" do + it 'should do mutation tracking for list attributes' do + klass.list_attr(:mt) + expect(klass.attributes[:mt].track_mutations?).to be_truthy + end + + it 'should do mutation tracking for map attributes' do + klass.map_attr(:mt) + expect(klass.attributes[:mt].track_mutations?).to be_truthy + end + + it 'should do mutation tracking for numeric set attributes' do + klass.numeric_set_attr(:mt) + expect(klass.attributes[:mt].track_mutations?).to be_truthy + end + + it 'should do mutation tracking for string set attributes' do + klass.string_set_attr(:mt) + expect(klass.attributes[:mt].track_mutations?).to be_truthy + end + + it 'can turn off mutation tracking at the attribute level' do + klass.list_attr(:mt, mutation_tracking: false) + expect(klass.attributes[:mt].track_mutations?).to be_falsy + end + end + end end end diff --git a/spec/aws-record/record/dirty_tracking_spec.rb b/spec/aws-record/record/dirty_tracking_spec.rb index aeb8634e..a4d423c2 100644 --- a/spec/aws-record/record/dirty_tracking_spec.rb +++ b/spec/aws-record/record/dirty_tracking_spec.rb @@ -267,4 +267,254 @@ end + describe "Mutation Dirty Tracking" do + let(:klass) do + Class.new do + include(Aws::Record) + set_table_name(:test_table) + string_attr(:mykey, hash_key: true) + string_attr(:body) + list_attr(:dirty_list) + map_attr(:dirty_map) + string_set_attr(:dirty_set) + list_attr(:notrack_list, mutation_tracking: false) + end + end + + describe "Tracking Turned Off" do + it 'does not track detailed mutations when mutation tracking is turned off' do + item = klass.new(mykey: "1", notrack_list: [1,2,3]) + item.clean! + item.notrack_list << 4 + expect(item.notrack_list).to eq([1,2,3,4]) + expect(item.dirty?).to be_falsy + end + + it 'does not track detailed mutations when tracking is globally off' do + klass.disable_mutation_tracking + item = klass.new(mykey: "1", dirty_list: [1,2,3]) + item.clean! + item.dirty_list << 4 + expect(item.dirty_list).to eq([1,2,3,4]) + expect(item.dirty?).to be_falsy + end + end + + describe "Lists" do + it 'marks mutated lists as dirty' do + item = klass.new(mykey: "1", dirty_list: [1,2,3]) + item.clean! + item.dirty_list << 4 + expect(item.dirty_list).to eq([1,2,3,4]) + expect(item.dirty?).to be_truthy + expect(item.attribute_dirty?(:dirty_list)).to be_truthy + end + + it 'has a copy of the mutated list to reference and can roll back' do + item = klass.new(mykey: "1", dirty_list: [1,2,3]) + item.clean! + item.dirty_list << 4 + expect(item.dirty_list_was).to eq([1,2,3]) + item.rollback!(:dirty_list) + expect(item.dirty_list).to eq([1,2,3]) + end + + it 'includes the mutated list in the list of dirty attributes' do + item = klass.new(mykey: "1", body: "b", dirty_list: [1,2,3]) + item.clean! + item.body = "body" + item.dirty_list << 4 + expect(item.dirty).to eq([:body, :dirty_list]) + end + + it 'correctly unmarks attributes as dirty when rolling back from copy' do + item = klass.new(mykey: "1", dirty_list: [1,2,3]) + item.clean! + item.attribute_dirty!(:dirty_list) + expect(item.dirty).to eq([:dirty_list]) + item.dirty_list << 4 + expect(item.dirty).to eq([:dirty_list]) + item.rollback_attribute!(:dirty_list) + expect(item.dirty?).to be_falsy + end + + it 'correctly handles #clean! with a mutated list' do + item = klass.new(mykey: "1", body: "b", dirty_list: [1,2,3]) + item.clean! + item.dirty_list << 4 + expect(item.dirty?).to be_truthy + item.clean! + expect(item.dirty?).to be_falsy + expect(item.attribute_was(:dirty_list)).to eq([1,2,3,4]) + end + + it 'correctly handles nested mutated lists' do + my_list = [[1], [1,2], [1,2,3]] + item = klass.new(mykey: "1", dirty_list: my_list) + item.clean! + expect(item.dirty?).to be_falsy + my_list[0] << 2 + my_list[1] << 3 + my_list[2] << 4 + expect(item.dirty_list).to eq([[1,2], [1,2,3], [1,2,3,4]]) + expect(item.dirty_list_was).to eq([[1], [1,2], [1,2,3]]) + expect(item.dirty?).to be_truthy + item.rollback_attribute!(:dirty_list) + expect(item.dirty_list).to eq([[1], [1,2], [1,2,3]]) + end + + it 'correctly handles list equality through assignment' do + item = klass.new(mykey: "1", dirty_list: [1,2,3]) + item.clean! + item.dirty_list << 4 + expect(item.dirty?).to be_truthy + item.dirty_list = [1,2,3] + expect(item.dirty?).to be_falsy + end + end + + describe "Maps" do + it 'marks mutated maps as dirty' do + item = klass.new(mykey: "1", dirty_map: { a: 1, b: '2' }) + item.clean! + item.dirty_map[:c] = 3.0 + expect(item.dirty_map).to eq({a: 1, b: '2', c: 3.0}) + expect(item.dirty?).to be_truthy + expect(item.attribute_dirty?(:dirty_map)).to be_truthy + end + + it 'has a copy of the mutated map to reference and can roll back' do + item = klass.new(mykey: "1", dirty_map: { a: 1, b: '2' }) + item.clean! + item.dirty_map[:c] = 3.0 + expect(item.dirty_map_was).to eq({a: 1, b: '2'}) + item.rollback!(:dirty_map) + expect(item.dirty_map).to eq({a: 1, b: '2'}) + end + + it 'includes the mutated map in the list of dirty attributes' do + item = klass.new(mykey: "1", body: "b", dirty_map: { a: 1, b: '2' }) + item.clean! + item.body = "body" + item.dirty_map[:c] = 3.0 + expect(item.dirty).to eq([:body, :dirty_map]) + end + + it 'correctly unmarks attributes as dirty when rolling back from copy' do + item = klass.new(mykey: "1", dirty_map: { a: 1, b: '2' }) + item.clean! + item.attribute_dirty!(:dirty_map) + expect(item.dirty).to eq([:dirty_map]) + item.dirty_map[:c] = 3.0 + expect(item.dirty).to eq([:dirty_map]) + item.rollback_attribute!(:dirty_map) + expect(item.dirty?).to be_falsy + end + + it 'correctly handles #clean! with a mutated map' do + item = klass.new(mykey: "1", dirty_map: { a: 1, b: '2' }) + item.clean! + item.dirty_map[:c] = 3.0 + expect(item.dirty?).to be_truthy + item.clean! + expect(item.dirty?).to be_falsy + expect(item.attribute_was(:dirty_map)).to eq({a: 1, b: '2', c: 3.0}) + end + + it 'correctly handles nested mutated maps' do + my_map = { + a: { one: 1, two: 2.0 }, + b: 2 + } + item = klass.new(mykey: "1", dirty_map: my_map) + item.clean! + expect(item.dirty?).to be_falsy + my_map[:a][:three] = "3" + my_map[:c] = { nesting: true } + expect(item.dirty_map).to eq({ + a: { one: 1, two: 2.0, three: "3" }, + b: 2, + c: { nesting: true } + }) + expect(item.dirty_map_was).to eq({ + a: { one: 1, two: 2.0 }, + b: 2 + }) + expect(item.dirty?).to be_truthy + item.rollback_attribute!(:dirty_map) + expect(item.dirty_map).to eq({ + a: { one: 1, two: 2.0 }, + b: 2 + }) + end + + it 'correctly handles map equality through assignment' do + item = klass.new(mykey: "1", dirty_map: { a: 1, b: '2' }) + item.clean! + item.dirty_map[:c] = 3.0 + expect(item.dirty?).to be_truthy + item.dirty_map = { a: 1, b: '2' } + expect(item.dirty?).to be_falsy + end + end + + describe "Sets" do + it 'marks mutated sets as dirty' do + item = klass.new(mykey: "1", dirty_set: Set.new(['a','b','c'])) + item.clean! + item.dirty_set.add('d') + expect(item.dirty_set).to eq(Set.new(['a','b','c','d'])) + expect(item.dirty?).to be_truthy + expect(item.attribute_dirty?(:dirty_set)).to be_truthy + end + + it 'has a copy of the mutated set to reference and can roll back' do + item = klass.new(mykey: "1", dirty_set: Set.new(['a','b','c'])) + item.clean! + item.dirty_set.add('d') + expect(item.dirty_set_was).to eq(Set.new(['a','b','c'])) + item.rollback!(:dirty_set) + expect(item.dirty_set).to eq(Set.new(['a','b','c'])) + end + + it 'includes the mutated set in the list of dirty attributes' do + item = klass.new(mykey: "1", body: "b", dirty_set: Set.new(['a','b','c'])) + item.clean! + item.body = "body" + item.dirty_set.add('d') + expect(item.dirty).to eq([:body, :dirty_set]) + end + + it 'correctly unmarks attributes as dirty when rolling back from copy' do + item = klass.new(mykey: "1", dirty_set: Set.new(['a','b','c'])) + item.clean! + item.attribute_dirty!(:dirty_set) + expect(item.dirty).to eq([:dirty_set]) + item.dirty_set.add('d') + expect(item.dirty).to eq([:dirty_set]) + item.rollback_attribute!(:dirty_set) + expect(item.dirty?).to be_falsy + end + + it 'correctly handles #clean! with a mutated set' do + item = klass.new(mykey: "1", dirty_set: Set.new(['a','b','c'])) + item.clean! + item.dirty_set.add('d') + expect(item.dirty?).to be_truthy + item.clean! + expect(item.dirty?).to be_falsy + expect(item.attribute_was(:dirty_set)).to eq(Set.new(['a','b','c','d'])) + end + + it 'correctly handles set equality through assignment' do + item = klass.new(mykey: "1", dirty_set: Set.new(['a','b','c'])) + item.clean! + item.dirty_set.add('d') + expect(item.dirty?).to be_truthy + item.dirty_set = Set.new(['a','b','c']) + expect(item.dirty?).to be_falsy + end + end + end + end diff --git a/spec/aws-record/record_spec.rb b/spec/aws-record/record_spec.rb index 6b0791ee..5457f82a 100644 --- a/spec/aws-record/record_spec.rb +++ b/spec/aws-record/record_spec.rb @@ -127,5 +127,26 @@ module Aws end end + + describe "#track_mutations" do + let(:model) { + Class.new do + include(Aws::Record) + set_table_name("TestTable") + string_attr(:uuid, hash_key: true) + attr(:mt, Aws::Record::Attributes::StringMarshaler, mutation_tracking: true) + end + } + + it 'supports mutation tracking for the appropriate attributes by default' do + expect(model.track_mutations?(:mt)).to be_truthy + end + + it 'can turn off mutation tracking globally for a model' do + model.disable_mutation_tracking + expect(model.track_mutations?(:mt)).to be_falsy + end + end + end end