Module: RightSupport::Data::HashTools

Defined in:
lib/right_support/data/hash_tools.rb

Overview

various tools for manipulating hash-like classes.

Defined Under Namespace

Classes: DeepSortedJsonState, NoJson

Constant Summary collapse

HAS_JSON =

require checks

require_succeeds?('json')

Class Method Summary collapse

Class Method Details

.canonicalize_header_key(key) ⇒ Object

provides a canonical representation of a header key that is acceptable to Rack, etc. the web standard for canonical header keys is all lowercase except with the first character capitalized at the start and after each dash. underscores are converted to dash characters.



494
495
496
# File 'lib/right_support/data/hash_tools.rb', line 494

def self.canonicalize_header_key(key)
  key.to_s.downcase.gsub('_', '-').gsub(/(^|-)(.)/) { $1 + $2.upcase }
end

.canonicalize_headers(headers) ⇒ Hash

duplicates the given headers hash after canonicalizing header keys.

Parameters:

  • headers (Hash)

    to canonicalize

Returns:

  • (Hash)

    canonicalized headers



539
540
541
542
543
544
# File 'lib/right_support/data/hash_tools.rb', line 539

def self.canonicalize_headers(headers)
  headers.inject({}) do |h, (k, v)|
    h[canonicalize_header_key(k)] = v
    h
  end
end

.deep_apply_diff!(target, diff) ⇒ Object

Recursively apply diff portion of a patch (generated by deep_create_patch, etc.).

Parameters

Return

Parameters:

  • target (Hash)

    hash where diff will be applied.

  • diff (Hash)

    hash containing changes to apply.

  • target (Hash)

    hash with diff applied.



392
393
394
395
396
397
398
399
400
401
# File 'lib/right_support/data/hash_tools.rb', line 392

def self.deep_apply_diff!(target, diff)
  diff.each do |k, v|
    if v[:left] && v[:right]
      target[k] = v[:right] if v[:left] == target[k]
    elsif target.has_key?(k)
      deep_apply_diff!(target[k], v)
    end
  end
  target
end

.deep_apply_patch!(target, patch) ⇒ Object

Perform 3-way merge using given target and a patch hash (generated by deep_create_patch, etc.). values in target whose keys are in :left_only component of patch are removed values in :right_only component of patch get deep merged into target values in target whose keys are in :diff component of patch and which are identical to left side of diff get overwritten with right side of patch

Parameters

Return

Parameters:

  • target (Hash)

    hash where patch will be applied.

  • patch (Hash)

    hash containing changes to apply.

  • target (Hash)

    hash with patch applied.



377
378
379
380
381
382
# File 'lib/right_support/data/hash_tools.rb', line 377

def self.deep_apply_patch!(target, patch)
  deep_remove!(target, patch[:left_only])
  deep_merge!(target, patch[:right_only])
  deep_apply_diff!(target, patch[:diff])
  target
end

.deep_clone(original) {|value| ... } ⇒ Hash

Deprecated.

in favor of more robust deep_clone2

Creates a deep clone of the given hash.

note that not all objects are clonable in Ruby even though all respond to clone (which is completely counter-intuitive and contrary to all other managed languages). Java, for example, has the built-in Cloneable marker interface which we will simulate here with .duplicable? (because cloneable isn’t actually a word) in case you need non-hash values to be deep-cloned by this method.

also note that .duplicable? may imply caling .dup instead of .clone but developers tend to override .clone and totally forget to override .dup (and having two names for the same method is, yes, a bad idea in any language).

Parameters

Block

Return

Parameters:

  • original (Hash)

    hash to clone

Yield Parameters:

Yield Returns:

  • (Object)

    cloned value of leaf or original value

Returns:

  • (Hash)

    deep cloned hash



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/right_support/data/hash_tools.rb', line 140

def self.deep_clone(original, &leaf_callback)
  # note that .clone preserves .frozen? state so this legacy method will
  # attempt to modify a still-frozen hash/array and raise an exception.
  # see deep_clone2 which clones by creating a new instance of the object
  # class and produce an unfrozen clone of the hash/array.
  # as a side note, calling .dup does not preserve .frozen? state but is has
  # its own side effects, which is why we have more specific logic like
  # leaf_callback and .duplicable?
  result = original.clone
  result.each do |k, v|
    if hashable?(v)
      # HACK: we should have passed &leaf_callback here but never did before
      # so it is techincally a bug. we are not going to change it due to not
      # wanting to break legacy code so use deep_clone2 instead.
      result[k] = deep_clone(v)
    elsif leaf_callback
      result[k] = leaf_callback.call(v)
    elsif v.respond_to?(:duplicable?)
      result[k] = (v.duplicable? ? v.clone : v)
    else
      result[k] = v
    end
  end
  result
end

.deep_clone2(any, options = {}) {|value| ... } ⇒ Object

Deeply duplicates (clones) a hashable object containing other hashes or arrays of hashes but passing other types through.

Optionally changes the target hashable type to ‘normalize’ to a specific hashable class (such as Mash).

Optionally traverses arrays (default) in an attempt to deep-clone any sub-hashes instead of simply associating them.

Parameters

Block

Return

Parameters:

  • any (Object)

    kind of object

  • options (Hash) (defaults to: {})
  • [Class] (Hash)

    a customizable set of options

Yield Parameters:

Yield Returns:

  • (Object)

    cloned value of leaf or original value

Returns:

  • (Object)

    depends on input type



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/right_support/data/hash_tools.rb', line 186

def self.deep_clone2(any, options = {}, &leaf_callback)
  if hashable?(any)
    # clone to a new instance of hashable class with deep cloning.
    any.inject((options[:class] || any.class).new) do |m, (k, v)|
      m[k] = deep_clone2(v, options, &leaf_callback)
      m
    end
  elsif any.kind_of?(::Array)
    # traverse arrays
    any.map { |e| deep_clone2(e, options, &leaf_callback) }
  elsif leaf_callback
    leaf_callback.call(any)
  elsif any.respond_to?(:duplicable?)
    # see #deep_clone for remarks
    any.duplicable? ? any.clone : any
  else
    any  # whatever
  end
end

.deep_create_patch(left, right) ⇒ Hash

Produce a difference from two hashes. The difference excludes any values which are common to both hashes.

The result is a hash with the following keys:

- :diff = hash with key common to both input hashes and value composed of the corresponding different values: { :left => <left value>, :right => <right value> }
- :left_only = hash composed of items only found in left hash
- :right_only = hash composed of items only found in right hash

Parameters

Return

Parameters:

  • left (Hash)

    side

  • right (Hash)

    side

Returns:

  • (Hash)

    result as hash of diffage



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/right_support/data/hash_tools.rb', line 345

def self.deep_create_patch(left, right)
  result = { :diff=>{}, :left_only=>{}, :right_only=>{} }
  right.each do |k, v|
    if left.include?(k)
      if hashable?(v) && hashable?(left[k])
        subdiff = deep_create_patch(left[k], v)
        result[:right_only].merge!(k=>subdiff[:right_only]) unless subdiff[:right_only].empty?
        result[:left_only].merge!(k=>subdiff[:left_only]) unless subdiff[:left_only].empty?
        result[:diff].merge!(k=>subdiff[:diff]) unless subdiff[:diff].empty?
      elsif v != left[k]
        result[:diff].merge!(k=>{:left => left[k], :right=>v})
      end
    else
      result[:right_only].merge!({ k => v })
    end
  end
  left.each { |k, v| result[:left_only].merge!({ k => v }) unless right.include?(k) }
  result
end

.deep_freeze!(any) ⇒ Object

Deeply freezes the given hash/array and all of their non-hierarchical elements in a hierarchichal data structure such that an attempt to modify any part of it will raise an exception.

Parameters

Return

Parameters:

  • any (Object)

    kind of object

Returns:

  • (Object)

    deeply frozen object



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/right_support/data/hash_tools.rb', line 215

def self.deep_freeze!(any)
  if hashable?(any)
    # clone to a new instance of hashable class with deep cloning.
    any.each do |k, v|
      # notes:
      #  a) either key or value of a hash could be a complex type.
      #  b) Hash freezes simple type keys upon insertion out of necessity to
      #     avoid spontaneous rehashing. Hash will not freeze complex types
      #     used as keys so we will (re)freeze all keys for safety.
      deep_freeze!(k)
      deep_freeze!(v)
    end
  elsif any.kind_of?(::Array)
    # traverse arrays
    any.each { |e| deep_freeze!(e) }
  end
  any.freeze
end

.deep_get(hash, path) ⇒ Object

Gets a value from a (deep) hash using a path given as an array of keys.

Parameters

Return

Parameters:

  • hash (Hash)

    for lookup or nil or empty

  • path (Array)

    to existing value as array of keys or nil or empty

Returns:



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/right_support/data/hash_tools.rb', line 69

def self.deep_get(hash, path)
  hash ||= {}
  path ||= []
  last_index = path.size - 1
  path.each_with_index do |key, index|
    value = hash[key]
    if index == last_index
      return value
    elsif hashable?(value)
      hash = value
    else
      break
    end
  end
  nil
end

.deep_mash(any) {|value| ... } ⇒ Object

Deeply mashes and duplicates (clones) a hashable using deep_clone2 and our own version of Mash.

The advantage of Mash over Hash is, of course, to be able to use either a String or Symbol as a key for the same value.

Note that Mash.new(my_mash) will convert child hashes to mashes but not with the guarantee of cloning and detaching the deep mash. In other words. if any part of the hash is already a mash then it is not cloned by invoking Mash.new()

Parameters

Block

Return

Parameters:

  • any (Object)

    kind of object

Yield Parameters:

Yield Returns:

  • (Object)

    cloned value of leaf or original value

Returns:

  • (Object)

    depends on input type



254
255
256
257
# File 'lib/right_support/data/hash_tools.rb', line 254

def self.deep_mash(any, &leaf_callback)
  options = { :class => RightSupport::Data::Mash }
  deep_clone2(any, options, &leaf_callback)
end

.deep_merge(target, source) ⇒ Hash

Performs a deep clone and merge of one hash into another, similar in behavior to Hash#merge

Parameters

Return

Parameters:

  • target (Hash)

    hash containing original data to be replaced in the resulting hash

  • source (Hash)

    hash containing data to recursively assign or nil or empty

Returns:

  • (Hash)

    to_hash result of merge



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/right_support/data/hash_tools.rb', line 270

def self.deep_merge(target, source)
  # merge or replace any values that appear in source.
  result = target.class.new
  (source || {}).each do |k, v|
    if hashable?(target[k]) && hashable?(v)
      result[k] = deep_merge(target[k], v)
    else
      result[k] = v
    end
  end

  # deep clone any target-only value.
  target.each do |k, v|
    unless result.has_key?(k)
      result[k] = deep_clone2(v)
    end
  end
  result
end

.deep_merge!(target, source) ⇒ Hash

Performs a deep merge (but not a deep clone) of one hash into another, similar in behavior to Hash#merge!

Parameters

Return

Parameters:

  • target (Hash)

    hash to contain original and merged data

  • source (Hash)

    hash containing data to recursively assign or nil or empty

Returns:

  • (Hash)

    to_hash result of merge



299
300
301
302
303
304
305
306
307
308
# File 'lib/right_support/data/hash_tools.rb', line 299

def self.deep_merge!(target, source)
  source.each do |k, v|
    if hashable?(target[k]) && hashable?(v)
      deep_merge!(target[k], v)
    else
      target[k] = v
    end
  end if source
  target
end

.deep_remove!(target, source) ⇒ Hash

Remove recursively values that exist equivalently in the match hash.

Parameters

Return

Parameters:

  • target (Hash)

    hash from which to remove matching values

  • source (Hash)

    hash to compare against target for removal or nil or empty

Returns:

  • (Hash)

    target hash with matching values removed, if any



318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/right_support/data/hash_tools.rb', line 318

def self.deep_remove!(target, source)
  source.each do |k, v|
    if target.has_key?(k)
      if target[k] == v
        target.delete(k)
      elsif hashable?(v) && hashable?(target[k])
        deep_remove!(target[k], v)
      end
    end
  end if source
  target
end

.deep_set!(hash, path, value, clazz = nil) ⇒ TrueClass

Set a given value on a (deep) hash using a path given as an array of keys.

Parameters

Return

Parameters:

  • hash (Hash)

    for insertion

  • path (Array)

    to new value as array of keys

  • value (Object)

    to insert

  • clazz (Class) (defaults to: nil)

    to allocate as needed when building deep hash or nil to infer from hash argument

Returns:

  • (TrueClass)

    always true

Raises:

  • (ArgumentError)


96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/right_support/data/hash_tools.rb', line 96

def self.deep_set!(hash, path, value, clazz=nil)
  raise ArgumentError.new("hash is invalid") unless hashable?(hash)
  raise ArgumentError.new("path is invalid") if path.empty?
  clazz ||= hash.class
  raise ArgumentError.new("clazz is invalid") unless hash_like?(clazz)
  last_index = path.size - 1
  path.each_with_index do |key, index|
    if index == last_index
      hash[key] = value
    else
      subhash = hash[key]
      unless hashable?(subhash)
        subhash = clazz.new
        hash[key] = subhash
      end
      hash = subhash
    end
  end
  true
end

.deep_sorted_json(hash, pretty = false) ⇒ String

Generates JSON from the given hash (of hashes) that is sorted by key at all levels. Does not handle case of hash to array of hashes, etc.

Parameters

Return

Parameters:

  • hash (Hash)

    from which to generate JSON

  • pretty (TrueClass|FalseClass) (defaults to: false)

    is true to invoke JSON::pretty_generate, false to call JSON::dump

Returns:

  • (String)

    result as a deep-sorted JSONized hash



480
481
482
483
484
485
486
487
488
# File 'lib/right_support/data/hash_tools.rb', line 480

def self.deep_sorted_json(hash, pretty=false)
  if HAS_JSON
    raise ArgumentError("'hash' was not hashable") unless hashable?(hash)
    state = ::RightSupport::Data::HashTools::DeepSortedJsonState.new(pretty)
    state.generate(hash)
  else
    raise NoJson, "JSON is unavailable"
  end
end

.hash_like?(clazz) ⇒ TrueClass|FalseClass

Determines if given class is hash-like (i.e. instance of class responds to hash methods).

Parameters

Return

Parameters:

  • clazz (Class)

    to be tested

Returns:

  • (TrueClass|FalseClass)

    true if clazz is hash-like, false otherwise



57
58
59
# File 'lib/right_support/data/hash_tools.rb', line 57

def self.hash_like?(clazz)
  clazz.public_method_defined?('has_key?')
end

.hashable?(object) ⇒ TrueClass|FalseClass

Determines if given object is hashable (i.e. object responds to hash methods).

Parameters

Return

Parameters:

  • object (Object)

    to be tested

Returns:

  • (TrueClass|FalseClass)

    true if object is hashable, false otherwise



41
42
43
44
45
46
47
48
# File 'lib/right_support/data/hash_tools.rb', line 41

def self.hashable?(object)
  # note that we could obviously be more critical here, but this test has always been
  # sufficient and excludes Arrays and Strings which respond to [] but not has_key?
  #
  # what we specifically don't want to do is .kind_of?(Hash) because that excludes the
  # range of hash-like classes (such as Chef::Node::Attribute)
  object.respond_to?(:has_key?)
end

.header_value_get(headers, key) ⇒ String

Returns header value by canonical key name, if present.

Returns:

  • (String)

    header value by canonical key name, if present.



499
500
501
502
503
504
505
506
507
508
509
510
# File 'lib/right_support/data/hash_tools.rb', line 499

def self.header_value_get(headers, key)
  return nil unless headers
  canonical_key = canonicalize_header_key(key)
  value = nil
  headers.each do |k, v|
    if canonicalize_header_key(k) == canonical_key
      value = v
      break
    end
  end
  value
end

.header_value_set(headers, key, value) ⇒ Hash

safely sets a header value by first locating any existing key by canonical search. if you know that the header hash is already canonical then you can alternatively set by using the canonicalized key.

Parameters:

  • headers (Hash)

    to modify

  • key (String)

    for header

  • value (String)

    for header

Returns:

  • (Hash)

    updated headers



521
522
523
524
525
526
527
528
529
530
531
532
# File 'lib/right_support/data/hash_tools.rb', line 521

def self.header_value_set(headers, key, value)
  headers ||= {}
  canonical_key = canonicalize_header_key(key)
  headers.each do |k, v|
    if canonicalize_header_key(k) == canonical_key
      key = k
      break
    end
  end
  headers[key] = value
  headers
end

.merge_headers(target, source) ⇒ Hash

merges source headers hash into the duplicated target headers canonically. also supports removal of a target header when the source value is nil.

Parameters:

  • target (Hash)

    to merge to

  • source (String)

    to merge from

Returns:

  • (Hash)

    merged headers



552
553
554
555
556
557
558
559
560
561
562
563
# File 'lib/right_support/data/hash_tools.rb', line 552

def self.merge_headers(target, source)
  target = canonicalize_headers(target)
  (source || {}).each do |k, v|
    canonical_key = canonicalize_header_key(k)
    if v.nil?
      target.delete(canonical_key)
    else
      target[canonical_key] = v
    end
  end
  target
end