Created
July 8, 2011 19:27
-
-
Save apeiros/1072609 to your computer and use it in GitHub Desktop.
A hash preserving changes made to it
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'hash/dirty' | |
# DirtyHash behaves just like Hash, but keeps track of changes applied to it. | |
# | |
# @example | |
# dh = DirtyHash.with :x => 1 | |
# dh.clean? # => true | |
# dh.update :y => 2, :z => 4 | |
# dh.changed # => {:y => [:added, 2], :z => [:added, nil, 4]} | |
# dh[:z] = 3 | |
# dh.changed # => {:y => [:added, 2], :z => [:replaced, 4, 3]} | |
# dh.reject! { |key, value| value > 2 } | |
# dh.changed # => {:y => [:added, 2], :z => [:deleted, 3, nil]} | |
# dh.dirty? # => true | |
# dh.clean! | |
# dh.changed # => {} | |
# | |
# @note | |
# The behaviour of the following methods remains to be defined: ==, eql?, hash | |
class DirtyHash < Hash | |
include Hash::Dirty | |
# Create a DirtyHash with prepopulated data. | |
# The returned DirtyHash is clean. | |
# If a second argument is provided, the first argument is used as default value. | |
# A block is given, it is used as default_proc. | |
# | |
# @overload with(:prefilled => 'data') | |
# @param [Hash, DirtyHash, #to_hash] prefilled_data | |
# The data to fill the dirty hash with | |
# | |
# @overload with(:default_value, :prefilled => 'data') | |
# @param [Object] default | |
# The default value, it is returned instead of nil when trying to access a yet undefined key | |
# @param [Hash, DirtyHash, #to_hash] prefilled_data | |
# The data to fill the dirty hash with | |
# | |
# @overload with(:prefilled => 'data', &default_proc) | |
# @param [Hash, DirtyHash, #to_hash] prefilled_data | |
# The data to fill the dirty hash with | |
# @yield [key, dirty_hash] | |
# The default proc will be called with the hash object and the key, and should return the | |
# default value. It is the block's responsibility to store the value in the hash if required. | |
# | |
# @example | |
# dh1 = DirtyHash#with :x => "hello", :y => "world" # => DirtyHash{:x=>"hello",:y=>"world"} | |
# dh2 = DirtyHash#with "foo", :x => "hello" # => DirtyHash{:x=>"hello"} | |
# dh2[:undefined] # => "foo" | |
# dh3 = DirtyHash#with :x => 1 do |dh,key| dh[key] = "bar" end | |
# dh3[:becoming_defined] # => "bar" | |
# dh3 # => DirtyHash{:x=>1,:becoming_defined=>"bar"} | |
# | |
# @see DirtyHash::new | |
def self.with(*args, &block) | |
raise ArgumentError, "wrong number of arguments (0 for 1)" if args.empty? | |
prefilled_data = args.pop | |
dirty_hash = new(*args, &block) | |
unless prefilled_data.is_a?(Hash) then | |
if prefilled_data.respond_to?(:to_hash) then | |
prefilled_data = prefilled_data.to_hash | |
else | |
raise ArgumentError, "prefilled_data must respond to to_hash" | |
end | |
end | |
dirty_hash.update(prefilled_data) | |
dirty_hash.clean_changes! | |
dirty_hash | |
end | |
def self.[](*) | |
dirty_hash = super | |
dirty_hash.send :initialize_dirty | |
dirty_hash.clean_changes! | |
dirty_hash | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Hash | |
# Terminology: | |
# add/added: A key with a value was added to the hash | |
# delete/deleted: A key and its value have been deleted from the hash | |
# replace/replaced: A key for which the value has been changed (not to be confused with the Hash method named 'replace') | |
# change/changed: Any of add, delete or replace. | |
# | |
# @example | |
# hash = {} | |
# hash.extend Hash::Dirty | |
# hash.changed? # => false | |
# | |
# @see DirtyHash | |
module Dirty | |
def self.extended(obj) | |
obj.__send__ :initialize_dirty | |
obj.clean_changes! | |
end | |
attr_reader :previous | |
# @see Hash::new | |
def initialize(*args) | |
super | |
initialize_dirty | |
end | |
def initialize_dirty | |
@previous = {} | |
@old_self = nil | |
@cache = {} | |
end | |
# The data that has changed in the hash. | |
# | |
# @return [Hash] | |
# A hash in the form \{key => change}, where change is an Array of the form | |
# [mutation, old_value, new_value], which means it is one of [:added, nil, value], | |
# [:changed, old_value, new_value] or [:deleted, value, nil]. | |
def changed | |
added.merge(replaced).merge(deleted) | |
end | |
def added_key?(key) | |
[email protected]?(key) && key?(key) | |
end | |
# @return [Array] The keys that have been added | |
# @see #added | |
# @see #replaced_keys | |
# @see #deleted_keys | |
# @see #clean! | |
def added_keys | |
[email protected] | |
end | |
# @return [Hash] The keys that have been added and their current value | |
# @see #added_keys | |
# @see #replaced | |
# @see #deleted | |
# @see #clean! | |
def added | |
relevant_keys = added_keys | |
mods = Array.new(relevant_keys.size, :added) | |
nils = Array.new(relevant_keys.size) | |
new_values = values_at(*relevant_keys) | |
Hash[relevant_keys.zip(mods.zip(nils, new_values))] | |
end | |
def deleted_key?(key) | |
@previous.key?(key) && !key?(key) | |
end | |
# @return [Array] The keys that have been deleted | |
# @see #deleted | |
# @see #added_keys | |
# @see #replaced_keys | |
# @see #clean! | |
def deleted_keys | |
@previous.keys-keys | |
end | |
# @return [Hash] The keys that have been deleted and their current value | |
# @see #deleted_keys | |
# @see #added | |
# @see #replaced | |
# @see #clean! | |
def deleted | |
relevant_keys = deleted_keys | |
mods = Array.new(relevant_keys.size, :deleted) | |
old_values = @previous.values_at(*relevant_keys) | |
nils = Array.new(relevant_keys.size) | |
Hash[relevant_keys.zip(mods.zip(old_values, nils))] | |
end | |
def replaced_key?(key) | |
@previous.key?(key) && key?(key) && !@previous[key].eql?(self[key]) | |
end | |
# @return [Array] The keys that have been replaced | |
# @see #replaced | |
# @see #added_keys | |
# @see #deleted_keys | |
# @see #clean! | |
def replaced_keys | |
(@previous.keys & keys).reject { |key| @previous[key].eql?(self[key]) } | |
end | |
# @return [Hash] | |
# The keys that have been replaced and an array with [old_value, new_value] | |
# | |
# @see #replaced_keys | |
# @see #added | |
# @see #deleted | |
# @see #clean! | |
def replaced | |
relevant_keys = replaced_keys | |
mods = Array.new(relevant_keys.size, :replaced) | |
old_values = @previous.values_at(*relevant_keys) | |
new_values = values_at(*relevant_keys) | |
Hash[relevant_keys.zip(mods.zip(old_values, new_values))] | |
end | |
def changed_keys | |
if eql?(@old_self) then | |
@cached_changed_keys | |
else | |
@old_self = dup | |
@cached_changed_keys = added_keys+replaced_keys+deleted_keys | |
end | |
end | |
def changed_key?(key) | |
!(@previous.key?(key) == key?(key) && @previous[key].eql?(self[key])) | |
end | |
# @return [Boolean] Whether there are registered changes since the last #clean! | |
# @see #unchanged? | |
# @see #clean! | |
def changed? | |
@previous.hash == hash && added_keys.empty? && deleted_keys.empty? && replaced_keys.empty? | |
end | |
# Removes all data about changes. | |
# @return [self] | |
def clean_changes! | |
@previous = to_hash | |
self | |
end | |
# Reverts the hash to the state before the first modification | |
# @return [self] | |
# @see #clean! | |
def revert_changes! | |
replace(@previous) | |
end | |
# @see Object#inspect | |
def inspect | |
"#{self.class}#{super}" | |
end | |
# @return [Hash] A normal hash with all the keys and values of this DirtyHash | |
def to_hash | |
if default_proc then | |
Hash.new(&default_proc).replace(self) | |
else | |
Hash.new(default).replace(self) | |
end | |
end | |
private | |
# Like DirtyHash#store, but will not register any changes. | |
# It can even have the effect of purging previously present changes. | |
# | |
# @return [self] | |
# | |
# @see Hash#store | |
def store_without_dirty(key, value) | |
store(key, value) | |
@previous.store(key, value) | |
self | |
end | |
# Like DirtyHash#delete, but will not register any changes. | |
# It can even have the effect of purging previously present changes. | |
# | |
# @return [self] | |
# | |
# @see Hash#delete | |
def delete_without_dirty(key) | |
delete(key) | |
@previous.delete(key) | |
self | |
end | |
# Like DirtyHash#update, but will not register any changes. | |
# It can even have the effect of purging previously present changes. | |
# | |
# @return [self] | |
# | |
# @see Hash#update | |
def update_without_dirty(data) | |
update(data) | |
@previous.update(data) | |
self | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment