Class: Hm

Inherits:
Object
  • Object
show all
Defined in:
lib/hm.rb,
lib/hm/dig.rb,
lib/hm/algo.rb,
lib/hm/version.rb

Overview

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

Examples:

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 Also:

Defined Under Namespace

Modules: Algo, Dig

Constant Summary collapse

WILDCARD =
:*
MAJOR =
0
MINOR =
0
PATCH =
4
PRE =
nil
VERSION =
[MAJOR, MINOR, PATCH, PRE].compact.join('.')

Instance Method Summary collapse

Constructor Details

#initialize(collection) ⇒ Hm

Note:

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

Returns a new instance of Hm.

Parameters:

  • 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.



28
29
30
# File 'lib/hm.rb', line 28

def initialize(collection)
  @hash = Algo.deep_copy(collection)
end

Instance Method Details

#bury(*path, value) ⇒ self

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`.

Examples:

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}}}

Parameters:

  • path

    One key or list of keys leading to the target. ‘:*` is treated as each matched subpath.

  • value

    Any value to store at path

Returns:

  • (self)


116
117
118
119
120
121
122
# 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

#cleanupself

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

Examples:

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}

Returns:

  • (self)


332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# 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

#compactself

Removes all ‘nil` values, including nested structures.

Examples:

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}

Returns:

  • (self)


317
318
319
320
321
# 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) ⇒ Object

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.

Examples:

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

Parameters:

  • path

    Array of keys.

Returns:

  • Object found or ‘nil`,



54
55
56
# File 'lib/hm.rb', line 54

def dig(*path)
  Algo.visit(@hash, path) { |_, _, val| val }
end

#dig!(*path) {|collection, path, rest| ... } ⇒ Object

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

Examples:

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

Parameters:

  • path

    Array of keys.

Yield Parameters:

  • collection

    Substructure “inside” which we are currently looking

  • path

    Path that led us to non-existent value (including current key)

  • rest

    Rest of the requested path we’d need to look if here would not be a missing value.

Returns:

  • Object found or ‘nil`,



79
80
81
82
83
# 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) ⇒ self

Removes all specified pathes from input sequence.

Examples:

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}]}

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Returns:

  • (self)

See Also:



282
283
284
285
286
287
# 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) {|value| ... } ⇒ Array<Hash>

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

Examples:

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"}]}
# ]

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Yield Parameters:

  • value (Array)

    Current value to process.

Returns:

  • (Array<Hash>)

    Two hashes



457
458
459
460
461
462
463
464
# 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) {|memo, value| ... } ⇒ self

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

Examples:

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"}

Parameters:

  • 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.

Yield Parameters:

  • memo
  • value

Returns:

  • (self)


435
436
437
438
439
440
# 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, value| ... } ⇒ self

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

Examples:

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}

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Yield Parameters:

  • path (Array)

    Current path at which the value is found

  • value

    Current value

Yield Returns:

  • (true, false)

    Remove value (with corresponding key) if true.

Returns:

  • (self)

See Also:



405
406
407
408
409
410
411
412
413
414
415
416
417
418
# 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, value| ... } ⇒ self

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.

Examples:

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}]}

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Yield Parameters:

  • path (Array)

    Current path at which the value is found

  • value

    Current value

Yield Returns:

  • (true, false)

    Preserve value (with corresponding key) if true.

Returns:

  • (self)

See Also:



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# 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) ⇒ self

Preserves only specified pathes from input sequence.

Examples:

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"}]}

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Returns:

  • (self)

See Also:



300
301
302
303
304
305
306
307
# 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_hHash Also known as: to_hash

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.

Returns:

  • (Hash)


472
473
474
# File 'lib/hm.rb', line 472

def to_h
  @hash
end

#transform(keys_to_keys, &processor) {|value| ... } ⇒ self

Note:

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

Renames input pathes to target pathes, with wildcard support.

Examples:

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}}

Parameters:

  • 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.

  • processor (Proc)

    Optional block to process value with while moving.

Yield Parameters:

  • value

Returns:

  • (self)

See Also:



171
172
173
174
# 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) {|key| ... } ⇒ self

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.

Examples:

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}

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Yield Parameters:

  • key (Array)

    Current key to process.

Returns:

  • (self)

See Also:



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# 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) {|value| ... } ⇒ self

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.

Examples:

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"}

Parameters:

  • pathes (Array)

    List of pathes (each being singular key, or array of keys, including ‘:*` wildcard) to look at.

Yield Parameters:

  • value (Array)

    Current value to process.

Returns:

  • (self)

See Also:



256
257
258
259
260
261
262
263
264
265
266
267
# 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) {|value| ... } ⇒ self

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

Examples:

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}

Parameters:

  • 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.

  • processor (Proc)

    Optional block to process value with while copying.

Yield Parameters:

  • value

Returns:

  • (self)

See Also:



192
193
194
195
# 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: ->(*) {}) {|collection, path, value| ... } ⇒ self

Low-level collection walking mechanism.

Examples:

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!

Parameters:

  • path

    Path to values to visit, ‘:*` wildcard is supported.

  • not_found (Proc) (defaults to: ->(*) {})

    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).

Yield Parameters:

  • collection

    Current subcollection we are looking at

  • path (Array)

    Current path we are at (in place of ‘:*` wildcards there are real keys).

  • value

    Current value

Returns:

  • (self)


143
144
145
146
# File 'lib/hm.rb', line 143

def visit(*path, not_found: ->(*) {}, &block)
  Algo.visit(@hash, path, not_found: not_found, &block)
  self
end