class Hm

`Hm` is a wrapper for chainable, terse, idiomatic Hash modifications.

@example

order = {
  'items' => {
    '#1' => {'title' => 'Beef', 'price' => '18.00'},
    '#2' => {'title' => 'Potato', 'price' => '8.20'}
  }
}
Hm(order)
  .transform_keys(&:to_sym)
  .transform(%i[items *] => :items)
  .transform_values(%i[items * price], &:to_f)
  .reduce(%i[items * price] => :total, &:+)
  .to_h
# => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}], :total=>26.2}

@see #Hm

Constants

MAJOR
MINOR
PATCH
PRE
VERSION
WILDCARD

@private

Public Class Methods

new(collection) click to toggle source

@note

`Hm.new(collection)` is also available as top-level method `Hm(collection)`.

@param collection Any Ruby collection that has `#dig` method. Note though, that most of

transformations only work with hashes & arrays, while {#dig} is useful for anything diggable.
# File lib/hm.rb, line 28
def initialize(collection)
  @hash = Algo.deep_copy(collection)
end

Public Instance Methods

bury(*path, value) click to toggle source

Stores value into deeply nested collection. `path` supports wildcards (“store at each matched path”) the same way {#dig} and other methods do. If specified path does not exists, it is created, with a “rule of thumb”: if next key is Integer, Array is created, otherwise it is Hash.

Caveats:

  • when `:*`-referred path does not exists, just `:*` key is stored;

  • as most of transformational methods, `bury` does not created and tested to work with `Struct`.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}

Hm(order).bury(:items, 0, :price, 16.5).to_h
# => {:items=>[{:title=>"Beef", :price=>16.5}, {:title=>"Potato", :price=>8.2}], :total=>26.2}

# with wildcard
Hm(order).bury(:items, :*, :discount, true).to_h
# => {:items=>[{:title=>"Beef", :price=>18.0, :discount=>true}, {:title=>"Potato", :price=>8.2, :discount=>true}], :total=>26.2}

# creating nested structure (note that 0 produces Array item)
Hm(order).bury(:payments, 0, :amount, 20.0).to_h
# => {:items=>[...], :total=>26.2, :payments=>[{:amount=>20.0}]}

# :* in nested insert is not very useful
Hm(order).bury(:payments, :*, :amount, 20.0).to_h
# => {:items=>[...], :total=>26.2, :payments=>{:*=>{:amount=>20.0}}}

@param path One key or list of keys leading to the target. `:*` is treated as

each matched subpath.

@param value Any value to store at path @return [self]

# File lib/hm.rb, line 116
def bury(*path, value)
  Algo.visit(
    @hash, path,
    not_found: ->(at, pth, rest) { at[pth.last] = Algo.nest_hashes(value, *rest) }
  ) { |at, pth, _| at[pth.last] = value }
  self
end
cleanup() click to toggle source

Removes all “empty” values and subcollections (`nil`s, empty strings, hashes and arrays), including nested structures. Empty subcollections are removed recoursively.

@example

order = {items: [{title: "Beef", price: 18.2}, {title: '', price: nil}], total: 26.2}
Hm(order).cleanup.to_h
# => {:items=>[{:title=>"Beef", :price=>18.2}], :total=>26.2}

@return [self]

# File lib/hm.rb, line 332
def cleanup
  deletions = -1
  # We do several runs to delete recursively: {a: {b: [nil]}}
  # first: {a: {b: []}}
  # second: {a: {}}
  # third: {}
  # More effective would be some "inside out" visiting, probably
  until deletions.zero?
    deletions = 0
    Algo.visit_all(@hash) do |at, path, val|
      if val.nil? || val.respond_to?(:empty?) && val.empty?
        deletions += 1
        Algo.delete(at, path.last)
      end
    end
  end
  self
end
compact() click to toggle source

Removes all `nil` values, including nested structures.

@example

order = {items: [{title: "Beef", price: nil}, nil, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).compact.to_h
# => {:items=>[{:title=>"Beef"}, {:title=>"Potato", :price=>8.2}], :total=>26.2}

@return [self]

# File lib/hm.rb, line 317
def compact
  Algo.visit_all(@hash) do |at, path, val|
    Algo.delete(at, path.last) if val.nil?
  end
end
dig(*path) click to toggle source

Like Ruby's [#dig](docs.ruby-lang.org/en/2.4.0/Hash.html#method-i-dig), but supports wildcard key `:*` meaning “each item at this point”.

Each level of data structure should have `#dig` method, otherwise `TypeError` is raised.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).dig(:items, 0, :title)
# => "Beef"
Hm(order).dig(:items, :*, :title)
# => ["Beef", "Potato"]
Hm(order).dig(:items, 0, :*)
# => ["Beef", 18.0]
Hm(order).dig(:items, :*, :*)
# => [["Beef", 18.0], ["Potato", 8.2]]
Hm(order).dig(:items, 3, :*)
# => nil
Hm(order).dig(:total, :count)
# TypeError: Float is not diggable

@param path Array of keys. @return Object found or `nil`,

# File lib/hm.rb, line 54
def dig(*path)
  Algo.visit(@hash, path) { |_, _, val| val }
end
dig!(*path, &not_found) click to toggle source

Like {#dig!} but raises when key at any level is not found. This behavior can be changed by passed block.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).dig!(:items, 0, :title)
# => "Beef"
Hm(order).dig!(:items, 2, :title)
# KeyError: Key not found: :items/2
Hm(order).dig!(:items, 2, :title) { |collection, path, rest|
  puts "At #{path}, #{collection} does not have a key #{path.last}. Rest of path: #{rest}";
  111
}
# At [:items, 2], [{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}] does not have a key 2. Rest of path: [:title]
# => 111

@param path Array of keys. @yieldparam collection Substructure “inside” which we are currently looking @yieldparam path Path that led us to non-existent value (including current key) @yieldparam rest Rest of the requested path we'd need to look if here would not be a missing value. @return Object found or `nil`,

# File lib/hm.rb, line 79
def dig!(*path, &not_found)
  not_found ||=
    ->(_, pth, _) { fail KeyError, "Key not found: #{pth.map(&:inspect).join('/')}" }
  Algo.visit(@hash, path, not_found: not_found) { |_, _, val| val }
end
except(*pathes) click to toggle source

Removes all specified pathes from input sequence.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).except(%i[items * title]).to_h
# => {:items=>[{:price=>18.0}, {:price=>8.2}], :total=>26.2}
Hm(order).except([:items, 0, :title], :total).to_h
# => {:items=>[{:price=>18.0}, {:title=>"Potato", :price=>8.2}]}

@see slice @param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@return [self]

# File lib/hm.rb, line 282
def except(*pathes)
  pathes.each do |path|
    Algo.visit(@hash, path) { |what, pth, _| Algo.delete(what, pth.last) }
  end
  self
end
partition(*pathes) click to toggle source

Split hash into two: the one with the substructure matching `pathes`, and the with thos that do not.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).partition(%i[items * price], :total)
# => [
#  {:items=>[{:price=>18.0}, {:price=>8.2}], :total=>26.2},
#  {:items=>[{:title=>"Beef"}, {:title=>"Potato"}]}
# ]

@param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@yieldparam value [Array] Current value to process. @return [Array<Hash>] Two hashes

# File lib/hm.rb, line 457
def partition(*pathes)
  # FIXME: this implementation is naive, it performs 2 additional deep copies and 2 full cycles of
  # visiting instead of just splitting existing data in one pass. It works, though
  [
    Hm(@hash).slice(*pathes).to_h,
    Hm(@hash).except(*pathes).to_h
  ]
end
reduce(keys_to_keys, &block) click to toggle source

Calculates one value from several values at specified pathes, using specified block.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}]}
Hm(order).reduce(%i[items * price] => :total, &:+).to_h
# => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}], :total=>26.2}
Hm(order).reduce(%i[items * price] => :total, %i[items * title] => :title, &:+).to_h
# => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}], :total=>26.2, :title=>"BeefPotato"}

@param keys_to_keys [Hash] Each key-value pair of input hash represents “source path to take

values" => "target path to store result of reduce". Each can be single key or nested path,
including `:*` wildcard.

@yieldparam memo @yieldparam value @return [self]

# File lib/hm.rb, line 435
def reduce(keys_to_keys, &block)
  keys_to_keys.each do |from, to|
    bury(*to, dig(*from).reduce(&block))
  end
  self
end
reject(*pathes) { |path, val| ... } click to toggle source

Drops subset of the collection by provided block (optionally looking only at pathes specified).

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).reject { |path, val| val.is_a?(Float) && val < 10 }.to_h
# => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato"}], :total=>26.2}
Hm(order).reject(%i[items * price]) { |path, val| val < 10 }.to_h
# => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato"}], :total=>26.2}
Hm(order).reject(%i[items *]) { |path, val| val[:price] < 10 }.to_h
# => {:items=>[{:title=>"Beef", :price=>18.0}], :total=>26.2}

@see select @param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@yieldparam path [Array] Current path at which the value is found @yieldparam value Current value @yieldreturn [true, false] Remove value (with corresponding key) if true. @return [self]

# File lib/hm.rb, line 405
def reject(*pathes)
  if pathes.empty?
    Algo.visit_all(@hash) do |at, path, val|
      Algo.delete(at, path.last) if yield(path, val)
    end
  else
    pathes.each do |path|
      Algo.visit(@hash, path) do |at, pth, val|
        Algo.delete(at, pth.last) if yield(pth, val)
      end
    end
  end
  self
end
select(*pathes) { |path, val| ... } click to toggle source

Select subset of the collection by provided block (optionally looking only at pathes specified).

Method is added mostly for completeness, as filtering out wrong values is better done with {#reject}, and selecting just by subset of keys by {#slice} and {#except}.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).select { |path, val| val.is_a?(Float) }.to_h
# => {:items=>[{:price=>18.0}, {:price=>8.2}], :total=>26.2}
Hm(order).select([:items, :*, :price]) { |path, val| val > 10 }.to_h
# => {:items=>[{:price=>18.0}]}

@see reject @param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@yieldparam path [Array] Current path at which the value is found @yieldparam value Current value @yieldreturn [true, false] Preserve value (with corresponding key) if true. @return [self]

# File lib/hm.rb, line 370
def select(*pathes)
  res = Hm.new({})
  if pathes.empty?
    Algo.visit_all(@hash) do |_, path, val|
      res.bury(*path, val) if yield(path, val)
    end
  else
    pathes.each do |path|
      Algo.visit(@hash, path) do |_, pth, val|
        res.bury(*pth, val) if yield(pth, val)
      end
    end
  end
  @hash = res.to_h
  self
end
slice(*pathes) click to toggle source

Preserves only specified pathes from input sequence.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).slice(%i[items * title]).to_h
# => {:items=>[{:title=>"Beef"}, {:title=>"Potato"}]}

@see except @param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@return [self]

# File lib/hm.rb, line 300
def slice(*pathes)
  result = Hm.new({})
  pathes.each do |path|
    Algo.visit(@hash, path) { |_, new_path, val| result.bury(*new_path, val) }
  end
  @hash = result.to_h
  self
end
to_h() click to toggle source

Returns the result of all the processings inside the `Hm` object.

Note, that you can pass an Array as a top-level structure to `Hm`, and in this case `to_h` will return the processed Array… Not sure what to do about that currently.

@return [Hash]

# File lib/hm.rb, line 472
def to_h
  @hash
end
Also aliased as: to_hash
to_hash()
Alias for: to_h
transform(keys_to_keys, &processor) click to toggle source

Renames input pathes to target pathes, with wildcard support.

@note

Currently, only one wildcard per each from and to pattern is supported.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).transform(%i[items * price] => %i[items * price_cents]).to_h
# => {:items=>[{:title=>"Beef", :price_cents=>18.0}, {:title=>"Potato", :price_cents=>8.2}], :total=>26.2}
Hm(order).transform(%i[items * price] => %i[items * price_usd]) { |val| val / 100.0 }.to_h
# => {:items=>[{:title=>"Beef", :price_usd=>0.18}, {:title=>"Potato", :price_usd=>0.082}], :total=>26.2}
Hm(order).transform(%i[items *] => :*).to_h # copying them out
# => {:items=>[], :total=>26.2, 0=>{:title=>"Beef", :price=>18.0}, 1=>{:title=>"Potato", :price=>8.2}}

@see transform_keys @see transform_values @see update @param keys_to_keys [Hash] Each key-value pair of input hash represents “source path to take

values" => "target path to store values". Each can be single key or nested path,
including `:*` wildcard.

@param processor [Proc] Optional block to process value with while moving. @yieldparam value @return [self]

# File lib/hm.rb, line 171
def transform(keys_to_keys, &processor)
  keys_to_keys.each { |from, to| transform_one(Array(from), Array(to), &processor) }
  self
end
transform_keys(*pathes) { |last| ... } click to toggle source

Performs specified transformations on keys of input sequence, optionally limited only by specified pathes.

Note that when `pathes` parameter is passed, only keys directly matching the pathes are processed, not entire sub-collection under this path.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).transform_keys(&:to_s).to_h
# => {"items"=>[{"title"=>"Beef", "price"=>18.0}, {"title"=>"Potato", "price"=>8.2}], "total"=>26.2}
Hm(order)
  .transform_keys(&:to_s)
  .transform_keys(['items', :*, :*], &:capitalize)
  .transform_keys(:*, &:upcase).to_h
# => {"ITEMS"=>[{"Title"=>"Beef", "Price"=>18.0}, {"Title"=>"Potato", "Price"=>8.2}], "TOTAL"=>26.2}

@see transform_values @see transform @param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@yieldparam key [Array] Current key to process. @return [self]

# File lib/hm.rb, line 219
def transform_keys(*pathes)
  if pathes.empty?
    Algo.visit_all(@hash) do |at, path, val|
      if at.is_a?(Hash)
        at.delete(path.last)
        at[yield(path.last)] = val
      end
    end
  else
    pathes.each do |path|
      Algo.visit(@hash, path) do |at, pth, val|
        Algo.delete(at, pth.last)
        at[yield(pth.last)] = val
      end
    end
  end
  self
end
transform_values(*pathes) { |val| ... } click to toggle source

Performs specified transformations on values of input sequence, limited only by specified pathes.

If no `pathes` are passed, all “terminal” values (e.g. not diggable) are yielded and transformed.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).transform_values(%i[items * price], :total, &:to_s).to_h
# => {:items=>[{:title=>"Beef", :price=>"18.0"}, {:title=>"Potato", :price=>"8.2"}], :total=>"26.2"}
Hm(order).transform_values(&:to_s).to_h
# => {:items=>[{:title=>"Beef", :price=>"18.0"}, {:title=>"Potato", :price=>"8.2"}], :total=>"26.2"}

@see transform_keys @see transform @param pathes [Array] List of pathes (each being singular key, or array of keys, including

`:*` wildcard) to look at.

@yieldparam value [Array] Current value to process. @return [self]

# File lib/hm.rb, line 256
def transform_values(*pathes)
  if pathes.empty?
    Algo.visit_all(@hash) do |at, pth, val|
      at[pth.last] = yield(val) unless Dig.diggable?(val)
    end
  else
    pathes.each do |path|
      Algo.visit(@hash, path) { |at, pth, val| at[pth.last] = yield(val) }
    end
  end
  self
end
update(keys_to_keys, &processor) click to toggle source

Like {#transform}, but copies values instead of moving them (original keys/values are preserved).

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
Hm(order).update(%i[items * price] => %i[items * price_usd]) { |val| val / 100.0 }.to_h
# => {:items=>[{:title=>"Beef", :price=>18.0, :price_usd=>0.18}, {:title=>"Potato", :price=>8.2, :price_usd=>0.082}], :total=>26.2}

@see transform_keys @see transform_values @see transform @param keys_to_keys [Hash] Each key-value pair of input hash represents “source path to take

values" => "target path to store values". Each can be single key or nested path,
including `:*` wildcard.

@param processor [Proc] Optional block to process value with while copying. @yieldparam value @return [self]

# File lib/hm.rb, line 192
def update(keys_to_keys, &processor)
  keys_to_keys.each { |from, to| transform_one(Array(from), Array(to), remove: false, &processor) }
  self
end
visit(*path, not_found: ->(*) {} click to toggle source

Low-level collection walking mechanism.

@example

order = {items: [{title: "Beef", price: 18.0}, {title: "Potato"}]}
order.visit(:items, :*, :price,
  not_found: ->(at, path, rest) { puts "#{at} at #{path}: nothing here!" }
) { |at, path, val| puts "#{at} at #{path}: #{val} is here!" }
# {:title=>"Beef", :price=>18.0} at [:items, 0, :price]: 18.0 is here!
# {:title=>"Potato"} at [:items, 1, :price]: nothing here!

@param path Path to values to visit, `:*` wildcard is supported. @param not_found [Proc] Optional proc to call when specified path is not found. Params are `collection`

(current sub-collection where key is not found), `path` (current path) and `rest` (the rest
of path we need to walk).

@yieldparam collection Current subcollection we are looking at @yieldparam path [Array] Current path we are at (in place of `:*` wildcards there are real

keys).

@yieldparam value Current value @return [self]

# File lib/hm.rb, line 143
def visit(*path, not_found: ->(*) {}, &block)
  Algo.visit(@hash, path, not_found: not_found, &block)
  self
end

Private Instance Methods

transform_one(from, to, remove: true) { |val| ... } click to toggle source
# File lib/hm.rb, line 480
def transform_one(from, to, remove: true, &_processor) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
  to.count(:*) > 1 || from.count(:*) > 1 and
    fail NotImplementedError, 'Transforming to multi-wildcards is not implemented'

  from_values = {}
  Algo.visit(
    @hash, from,
    # [:item, :*, :price] -- if one item is priceless, should go to output sequence
    # ...but what if last key is :*? something like from_values.keys.last.succ or?..
    not_found: ->(_, path, rest) { from_values[path + rest] = nil }
  ) { |_, path, val| from_values[path] = block_given? ? yield(val) : val }
  except(from) if remove
  if (ti = to.index(:*))
    fi = from.index(:*) # TODO: what if `from` had no wildcard?

    # we unpack [:items, :*, :price] with keys we got from gathering values,
    # like [:items, 0, :price], [:items, 1, :price] etc.
    from_values
      .map { |key, val| [to.dup.tap { |a| a[ti] = key[fi] }, val] }
      .each { |path, val| bury(*path, val) }
  else
    val = from_values.count == 1 ? from_values.values.first : from_values.values
    bury(*to, val)
  end
end