Skip to content

Instantly share code, notes, and snippets.

@apeiros
Created July 8, 2011 19:27
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save apeiros/1072609 to your computer and use it in GitHub Desktop.
Save apeiros/1072609 to your computer and use it in GitHub Desktop.
A hash preserving changes made to it
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
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)
!@previous.key?(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
keys-@previous.keys
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