Project

hm

0.04
Low commit activity in last 3 years
No release in over a year
Hm is a library for clean, idiomatic and chainable processing of complicated Ruby structures, typically unpacked from JSON. It provides smart dig and bury, keys replacement, nested transformations and more.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
 Dependencies
 Project Readme

Hm? Hm!

Gem Version Build Status

Hm is an experimental Ruby gem trying to provide effective, idiomatic, chainable Hash modifications (transformations) DSL.

Showcase

api_json = <<-JSON
{
  "coord": {"lon": -0.13, "lat": 51.51},
  "weather": [{"id": 300, "main": "Drizzle", "description": "light intensity drizzle", "icon": "09d"}],
  "base": "stations",
  "main": {"temp": 280.32, "pressure": 1012, "humidity": 81, "temp_min": 279.15, "temp_max": 281.15},
  "visibility": 10000,
  "wind": {"speed": 4.1, "deg": 80},
  "clouds": {"all": 90},
  "dt": 1485789600,
  "sys": {"type": 1, "id": 5091, "message": 0.0103, "country": "GB", "sunrise": 1485762037, "sunset": 1485794875},
  "id": 2643743,
  "name": "London",
  "cod": 200
}
JSON

weather = JSON.parse(api_json)
pp Hm.new(weather)
  .transform_keys(&:to_sym)                         # symbolize all keys
  .except(:id, :cod, %i[sys id], %i[weather * id])  # remove some system values
  .transform(
    %i[main *] => :*,                               # move all {main: {temp: X}} to just {temp: X}
    %i[sys *] => :*,                                # same for :sys
    %i[coord *] => :coord,                          # gather values for coord.lat, coord.lng into Array in :coord
    [:weather, 0] => :weather,                      # move first of :weather Array to just :weather key
    dt: :timestamp                                  # rename :dt to :timestamp
  )
  .cleanup                                          # remove now empty main: {} and sys: {} hashes
  .transform_values(
    :timestamp, :sunrise, :sunset,
    &Time.method(:at))                              # parse timestamps
  .bury(:weather, :comment, 'BAD')                  # insert some random new key
  .to_h
# {
#  :coord=>[-0.13, 51.51],
#  :weather=> {:main=>"Drizzle", :description=>"light intensity drizzle", :icon=>"09d", :comment=>"BAD"},
#  :base=>"stations",
#  :visibility=>10000,
#  :wind=>{:speed=>4.1, :deg=>80},
#  :clouds=>{:all=>90},
#  :name=>"London",
#  :temp=>280.32,
#  :pressure=>1012,
#  :humidity=>81,
#  :temp_min=>279.15,
#  :temp_max=>281.15,
#  :type=>1,
#  :message=>0.0103,
#  :country=>"GB",
#  :sunrise=>2017-01-30 09:40:37 +0200,
#  :sunset=>2017-01-30 18:47:55 +0200,
#  :timestamp=>2017-01-30 17:20:00 +0200}
# }

Features/problems

  • Small, no-dependencies, no-monkey patching, just "plug and play";
  • Idiomatic, terse, chainable;
  • Very new and experimental, works on the cases I've extracted from different production problems and invented on the road, but may not work for yours;
  • Most of the methods work on Arrays and Hashes, but not on Struct and OpenStruct (which are dig-able in Ruby), though, base #dig and #dig! should work on them too;
  • Hm(hash).dig(...) works even on versions of Ruby before 2.3 (when native #dig was introduced);
  • API is subject to polish and change in future.

Usage

Install it with gem install hm or adding gem 'hm' in your Gemfile.

One of the most important concepts of Hm is "path" through the structure. It is the same list of keys Ruby's native #dig() supports, with one, yet powerful, addition: :* stands for each (works with any Enumerable that is met at the structure at this point):

order = {
  date: Date.today,
  items: [
    {title: 'Beer', price: 10.0},
    {title: 'Beef', price: 5.0},
    {title: 'Potato', price: 7.8}
  ]
}
Hm(order).dig(:items, :*, :price) # => [10.0, 5.0, 7.8]

On top of that, Hm provides a set of chainable transformations, which can be used this way:

Hm(some_hash)
  .transformation(...)
  .transformation(...)
  .transformation(...)
  .to_h # => return the processed hash

List of currently available transformations:

  • bury(:key1, :key2, :key3, value) — opposite to dig, stores value in a nested structure;
  • transform([:path, :to, :key] => [:other, :path], [:multiple, :*, :values] => [:other, :*]) — powerful key renaming, with wildcards support;
  • transform_keys(path, path, path) { |key| ... } — works with nested hashes (so you can just transform_keys(&:to_sym) to deep symbolize keys), and is able to limit processing to only specified pathes, like transform_keys([:order, :items, :*, :*], &:capitalize)
  • transform_values(path, path, path) { |key| ... }
  • update — same as transform, but copies source key to target ones, instead of moving;
  • slice(:key1, :key2, [:path, :to, :*, :key3]) — extracts only list of specified key pathes;
  • except(:key1, :key2, [:path, :to, :*, :key3]) — removes list of specified key pathes;
  • compact removes all nil values, including nested collections;
  • cleanup recursively removes all "empty" values (empty strings, hashes, arrays, nils);
  • select(path, path) { |val| ... } — selects only parts of hash that match specified pathes and specified block;
  • reject(path, path) { |val| ... } — drops parts of hash that match specified pathes and specified block;
  • reduce([:path, :to, :*, :values] => [:path, :to, :result]) { |memo, val| ... } — reduce several values into one, like reduce(%i[items * price] => :total, &:+).

Look at API docs for details about each method.

Further goals

Currently, I am planning to just use existing one in several projects and see how it will go. The ideas to where it can be developed further exist, though:

  • Just add more useful methods (like merge probably), and make their addition modular;
  • There is a temptation for more powerful "dig path language", I am looking for a real non-imaginary cases for those, theoretically pretty enchancements:
    • :** for arbitrary depth;
    • /foo/ and (0..1) for selecting key ranges;
    • [:weather, [:sunrise, :sunset]] for selecting weather.sunrise AND weather.sunset path;
    • [:items, {title: 'Potato'}] for selecting whole hashes from :items, which have title: 'Potato' in them.
  • Hm() idiom for storing necessary transformations in constants:
WEATHER_TRANSFORM = Hm()
  .tranform(%w[temp min] => :temp_min, %w[temp max] => :temp_max)
  .transform_values(:dt, &Time.method(:at))

# ...later...
weathers.map(&WEATHER_TRANSFORM)
  • "Inline expectations framework":
Hm(api_response)
  .expect(:results, 0, :id) # raises if api_response[:results][0][:id] is absent
  .transform(something, something) # continue with our tranformations

If you find something of the above useful for production use-cases, drop me a note (or GitHub issue; or, even better, PR!).

Prior art

Hash transformers:

Hash paths:

Author

Victor Shepelev aka @zverok

License

MIT