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