hm 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +183 -0
  4. data/lib/hm.rb +483 -0
  5. data/lib/hm/algo.rb +95 -0
  6. metadata +218 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 49f552a6aaf919dbd0d5909c5e88b6e16a949fe4
4
+ data.tar.gz: 5d0b3b428620f7542e276ba0431ff9278e256643
5
+ SHA512:
6
+ metadata.gz: 3f0e5d2db2a5bc0a0e5b39aa05aed137b235d4ec115739e063dc1bdde4eab15842a5220202e5eb4ffb4b42fdc4015cdacb0d97582ac79d4ac92a9f2422b4c881
7
+ data.tar.gz: 7fd96ca72e8218a82663176b38549f3688e040f6efc74d923bd815006571aae1c83bacf20ff4bb3a0f3609aac0d1ceedf42421019c3f1dff7708f87807780b94
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Victor 'zverok' Shepelev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,183 @@
1
+ # Hm? Hm!
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/hm.svg)](http://badge.fury.io/rb/hm)
4
+ [![Build Status](https://travis-ci.org/zverok/hm.svg?branch=master)](https://travis-ci.org/zverok/hm)
5
+
6
+ **Hm** is an experimental Ruby gem trying to provide effective, idiomatic, chainable **H**ash
7
+ **m**odifications (transformations) DSL.
8
+
9
+ ## Showcase
10
+
11
+ ```ruby
12
+ api_json = <<-JSON
13
+ {
14
+ "coord": {"lon": -0.13, "lat": 51.51},
15
+ "weather": [{"id": 300, "main": "Drizzle", "description": "light intensity drizzle", "icon": "09d"}],
16
+ "base": "stations",
17
+ "main": {"temp": 280.32, "pressure": 1012, "humidity": 81, "temp_min": 279.15, "temp_max": 281.15},
18
+ "visibility": 10000,
19
+ "wind": {"speed": 4.1, "deg": 80},
20
+ "clouds": {"all": 90},
21
+ "dt": 1485789600,
22
+ "sys": {"type": 1, "id": 5091, "message": 0.0103, "country": "GB", "sunrise": 1485762037, "sunset": 1485794875},
23
+ "id": 2643743,
24
+ "name": "London",
25
+ "cod": 200
26
+ }
27
+ JSON
28
+
29
+ weather = JSON.parse(api_json)
30
+ pp Hm.new(weather)
31
+ .transform_keys(&:to_sym) # symbolize all keys
32
+ .except(:id, :cod, %i[sys id], %i[weather * id]) # remove some system values
33
+ .transform(
34
+ %i[main *] => :*, # move all {main: {temp: X}} to just {temp: X}
35
+ %i[sys *] => :*, # same for :sys
36
+ %i[coord *] => :coord, # gather values for coord.lat, coord.lng into Array in :coord
37
+ [:weather, 0] => :weather, # move first of :weather Array to just :weather key
38
+ dt: :timestamp # rename :dt to :timestamp
39
+ )
40
+ .cleanup # remove now empty main: {} and sys: {} hashes
41
+ .transform_values(
42
+ :timestamp, :sunrise, :sunset,
43
+ &Time.method(:at)) # parse timestamps
44
+ .bury(:weather, :comment, 'BAD') # insert some random new key
45
+ .to_h
46
+ # {
47
+ # :coord=>[-0.13, 51.51],
48
+ # :weather=> {:main=>"Drizzle", :description=>"light intensity drizzle", :icon=>"09d", :comment=>"BAD"},
49
+ # :base=>"stations",
50
+ # :visibility=>10000,
51
+ # :wind=>{:speed=>4.1, :deg=>80},
52
+ # :clouds=>{:all=>90},
53
+ # :name=>"London",
54
+ # :temp=>280.32,
55
+ # :pressure=>1012,
56
+ # :humidity=>81,
57
+ # :temp_min=>279.15,
58
+ # :temp_max=>281.15,
59
+ # :type=>1,
60
+ # :message=>0.0103,
61
+ # :country=>"GB",
62
+ # :sunrise=>2017-01-30 09:40:37 +0200,
63
+ # :sunset=>2017-01-30 18:47:55 +0200,
64
+ # :timestamp=>2017-01-30 17:20:00 +0200}
65
+ # }
66
+ ```
67
+
68
+ ## Features/problems
69
+
70
+ * Small, no-dependencies, no-monkey patching, just "plug and play";
71
+ * Idiomatic, terse, chainable;
72
+ * Very new and experimental, works on the cases I've extracted from different production problems and
73
+ invented on the road, but may not work for yours;
74
+ * Most of the methods work on Arrays and Hashes, but not on `Struct` and `OpenStruct` (which are
75
+ `dig`-able in Ruby), though, base `#dig` and `#dig!` should work on them too;
76
+ * API is subject to polish and change in future.
77
+
78
+ ## Usage
79
+
80
+ Install it with `gem install hm` or adding `gem 'hm'` in your `Gemfile`.
81
+
82
+ One of the most important concepts of `Hm` is "path" through the structure. It is the same list of
83
+ keys Ruby's native `#dig()` supports, with one, yet powerful, addition: `:*` stands for `each` (works
84
+ with any `Enumerable` that is met at the structure at this point):
85
+
86
+ ```ruby
87
+ order = {
88
+ date: Date.today,
89
+ items: [
90
+ {title: 'Beer', price: 10.0},
91
+ {title: 'Beef', price: 5.0},
92
+ {title: 'Potato', price: 7.8}
93
+ ]
94
+ }
95
+ Hm(order).dig(:items, :*, :price) # => [10.0, 5.0, 7.8]
96
+ ```
97
+
98
+ On top of that, `Hm` provides a set of chainable transformations, which can be used this way:
99
+
100
+ ```ruby
101
+ Hm(some_hash)
102
+ .transformation(...)
103
+ .transformation(...)
104
+ .transformation(...)
105
+ .to_h # => return the processed hash
106
+ ```
107
+
108
+ List of currently available transformations:
109
+
110
+ * `bury(:key1, :key2, :key3, value)` — opposite to `dig`, stores value in a nested structure;
111
+ * `transform([:path, :to, :key] => [:other, :path], [:multiple, :*, :values] => [:other, :*])` —
112
+ powerful key renaming, with wildcards support;
113
+ * `transform_keys(path, path, path) { |key| ... }` — works with nested hashes (so you can just
114
+ `transform_keys(&:to_sym)` to deep symbolize keys), and is able to limit processing to only
115
+ specified pathes, like `transform_keys([:order, :items, :*, :*], &:capitalize)`
116
+ * `transform_values(path, path, path) { |key| ... }`
117
+ * `update` — same as `transform`, but copies source key to target ones, instead of moving;
118
+ * `slice(:key1, :key2, [:path, :to, :*, :key3])` — extracts only list of specified key pathes;
119
+ * `except(:key1, :key2, [:path, :to, :*, :key3])` — removes list of specified key pathes;
120
+ * `compact` removes all `nil` values, including nested collections;
121
+ * `cleanup` recursively removes all "empty" values (empty strings, hashes, arrays, `nil`s);
122
+ * `select(path, path) { |val| ... }` — selects only parts of hash that match specified pathes and
123
+ specified block;
124
+ * `reject(path, path) { |val| ... }` — drops parts of hash that match specified pathes and
125
+ specified block;
126
+ * `reduce([:path, :to, :*, :values] => [:path, :to, :result]) { |memo, val| ... }` — reduce several
127
+ values into one, like `reduce(%i[items * price] => :total, &:+)`.
128
+
129
+ Look at [API docs](http://www.rubydoc.info/gems/hm) for details about each method.
130
+
131
+ ## Further goals
132
+
133
+ Currently, I am planning to just use existing one in several projects and see how it will go. The
134
+ ideas to where it can be developed further exist, though:
135
+
136
+ * Just add more useful methods (like `merge` probably), and make their addition modular;
137
+ * There is a temptation for more powerful "dig path language", I am looking for a real non-imaginary
138
+ cases for those, theoretically pretty enchancements:
139
+ * `:**` for arbitrary depth;
140
+ * `/foo/` and `(0..1)` for selecting key ranges;
141
+ * `[:weather, [:sunrise, :sunset]]` for selecting `weather.sunrise` AND `weather.sunset` path;
142
+ * `[:items, {title: 'Potato'}]` for selecting whole hashes from `:items`, which have `title: 'Potato'`
143
+ in them.
144
+ * `Hm()` idiom for storing necessary transformations in constants:
145
+
146
+ ```ruby
147
+ WEATHER_TRANSFORM = Hm()
148
+ .tranform(%w[temp min] => :temp_min, %w[temp max] => :temp_max)
149
+ .transform_values(:dt, &Time.method(:at))
150
+
151
+ # ...later...
152
+ weathers.map(&WEATHER_TRANSFORM)
153
+ ```
154
+ * "Inline expectations framework":
155
+
156
+ ```ruby
157
+ Hm(api_response)
158
+ .expect(:results, 0, :id) # raises if api_response[:results][0][:id] is absent
159
+ .transform(something, something) # continue with our tranformations
160
+ ```
161
+
162
+ If you find something of the above useful for production use-cases, drop me a note (or GitHub issue;
163
+ or, even better, PR!).
164
+
165
+ ## Prior art
166
+
167
+ Hash transformers:
168
+
169
+ * https://github.com/solnic/transproc
170
+ * https://github.com/deseretbook/hashformer
171
+
172
+ Hash paths:
173
+
174
+ * https://github.com/nickcharlton/keypath-ruby
175
+ * https://github.com/maiha/hash-path
176
+
177
+ ## Author
178
+
179
+ [Victor Shepelev aka @zverok](https://zverok.github.io)
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,483 @@
1
+ # `Hm` is a wrapper for chainable, terse, idiomatic Hash modifications.
2
+ #
3
+ # @example
4
+ # order = {
5
+ # 'items' => {
6
+ # '#1' => {'title' => 'Beef', 'price' => '18.00'},
7
+ # '#2' => {'title' => 'Potato', 'price' => '8.20'}
8
+ # }
9
+ # }
10
+ # Hm(order)
11
+ # .transform_keys(&:to_sym)
12
+ # .transform(%i[items *] => :items)
13
+ # .transform_values(%i[items * price], &:to_f)
14
+ # .reduce(%i[items * price] => :total, &:+)
15
+ # .to_h
16
+ # # => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}], :total=>26.2}
17
+ #
18
+ # @see #Hm
19
+ class Hm
20
+ # @private
21
+ WILDCARD = :*
22
+
23
+ # @note
24
+ # `Hm.new(collection)` is also available as top-level method `Hm(collection)`.
25
+ #
26
+ # @param collection Any Ruby collection that has `#dig` method. Note though, that most of
27
+ # transformations only work with hashes & arrays, while {#dig} is useful for anything diggable.
28
+ def initialize(collection)
29
+ @hash = Algo.deep_copy(collection)
30
+ end
31
+
32
+ # Like Ruby's [#dig](https://docs.ruby-lang.org/en/2.4.0/Hash.html#method-i-dig), but supports
33
+ # wildcard key `:*` meaning "each item at this point".
34
+ #
35
+ # Each level of data structure should have `#dig` method, otherwise `TypeError` is raised.
36
+ #
37
+ # @example
38
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
39
+ # Hm(order).dig(:items, 0, :title)
40
+ # # => "Beef"
41
+ # Hm(order).dig(:items, :*, :title)
42
+ # # => ["Beef", "Potato"]
43
+ # Hm(order).dig(:items, 0, :*)
44
+ # # => ["Beef", 18.0]
45
+ # Hm(order).dig(:items, :*, :*)
46
+ # # => [["Beef", 18.0], ["Potato", 8.2]]
47
+ # Hm(order).dig(:items, 3, :*)
48
+ # # => nil
49
+ # Hm(order).dig(:total, :count)
50
+ # # TypeError: Float is not diggable
51
+ #
52
+ # @param path Array of keys.
53
+ # @return Object found or `nil`,
54
+ def dig(*path)
55
+ Algo.visit(@hash, path) { |_, _, val| val }
56
+ end
57
+
58
+ # Like {#dig!} but raises when key at any level is not found. This behavior can be changed by
59
+ # passed block.
60
+ #
61
+ # @example
62
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
63
+ # Hm(order).dig!(:items, 0, :title)
64
+ # # => "Beef"
65
+ # Hm(order).dig!(:items, 2, :title)
66
+ # # KeyError: Key not found: :items/2
67
+ # Hm(order).dig!(:items, 2, :title) { |collection, path, rest|
68
+ # puts "At #{path}, #{collection} does not have a key #{path.last}. Rest of path: #{rest}";
69
+ # 111
70
+ # }
71
+ # # At [:items, 2], [{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}] does not have a key 2. Rest of path: [:title]
72
+ # # => 111
73
+ #
74
+ # @param path Array of keys.
75
+ # @yieldparam collection Substructure "inside" which we are currently looking
76
+ # @yieldparam path Path that led us to non-existent value (including current key)
77
+ # @yieldparam rest Rest of the requested path we'd need to look if here would not be a missing value.
78
+ # @return Object found or `nil`,
79
+ def dig!(*path, &not_found)
80
+ not_found ||=
81
+ ->(_, pth, _) { fail KeyError, "Key not found: #{pth.map(&:inspect).join('/')}" }
82
+ Algo.visit(@hash, path, not_found: not_found) { |_, _, val| val }
83
+ end
84
+
85
+ # Stores value into deeply nested collection. `path` supports wildcards ("store at each matched
86
+ # path") the same way {#dig} and other methods do. If specified path does not exists, it is
87
+ # created, with a "rule of thumb": if next key is Integer, Array is created, otherwise it is Hash.
88
+ #
89
+ # Caveats:
90
+ #
91
+ # * when `:*`-referred path does not exists, just `:*` key is stored;
92
+ # * as most of transformational methods, `bury` does not created and tested to work with `Struct`.
93
+ #
94
+ # @example
95
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
96
+ #
97
+ # Hm(order).bury(:items, 0, :price, 16.5).to_h
98
+ # # => {:items=>[{:title=>"Beef", :price=>16.5}, {:title=>"Potato", :price=>8.2}], :total=>26.2}
99
+ #
100
+ # # with wildcard
101
+ # Hm(order).bury(:items, :*, :discount, true).to_h
102
+ # # => {:items=>[{:title=>"Beef", :price=>18.0, :discount=>true}, {:title=>"Potato", :price=>8.2, :discount=>true}], :total=>26.2}
103
+ #
104
+ # # creating nested structure (note that 0 produces Array item)
105
+ # Hm(order).bury(:payments, 0, :amount, 20.0).to_h
106
+ # # => {:items=>[...], :total=>26.2, :payments=>[{:amount=>20.0}]}
107
+ #
108
+ # # :* in nested insert is not very useful
109
+ # Hm(order).bury(:payments, :*, :amount, 20.0).to_h
110
+ # # => {:items=>[...], :total=>26.2, :payments=>{:*=>{:amount=>20.0}}}
111
+ #
112
+ # @param path One key or list of keys leading to the target. `:*` is treated as
113
+ # each matched subpath.
114
+ # @param value Any value to store at path
115
+ # @return [self]
116
+ def bury(*path, value)
117
+ Algo.visit(
118
+ @hash, path,
119
+ not_found: ->(at, pth, rest) { at[pth.last] = Algo.nest_hashes(value, *rest) }
120
+ ) { |at, pth, _| at[pth.last] = value }
121
+ self
122
+ end
123
+
124
+ # Low-level collection walking mechanism.
125
+ #
126
+ # @example
127
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato"}]}
128
+ # order.visit(:items, :*, :price,
129
+ # not_found: ->(at, path, rest) { puts "#{at} at #{path}: nothing here!" }
130
+ # ) { |at, path, val| puts "#{at} at #{path}: #{val} is here!" }
131
+ # # {:title=>"Beef", :price=>18.0} at [:items, 0, :price]: 18.0 is here!
132
+ # # {:title=>"Potato"} at [:items, 1, :price]: nothing here!
133
+ #
134
+ # @param path Path to values to visit, `:*` wildcard is supported.
135
+ # @param not_found [Proc] Optional proc to call when specified path is not found. Params are `collection`
136
+ # (current sub-collection where key is not found), `path` (current path) and `rest` (the rest
137
+ # of path we need to walk).
138
+ # @yieldparam collection Current subcollection we are looking at
139
+ # @yieldparam path [Array] Current path we are at (in place of `:*` wildcards there are real
140
+ # keys).
141
+ # @yieldparam value Current value
142
+ # @return [self]
143
+ def visit(*path, not_found: ->(*) {}, &block)
144
+ Algo.visit(@hash, path, not_found: not_found, &block)
145
+ self
146
+ end
147
+
148
+ # Renames input pathes to target pathes, with wildcard support.
149
+ #
150
+ # @note
151
+ # Currently, only one wildcard per each from and to pattern is supported.
152
+ #
153
+ # @example
154
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
155
+ # Hm(order).transform(%i[items * price] => %i[items * price_cents]).to_h
156
+ # # => {:items=>[{:title=>"Beef", :price_cents=>18.0}, {:title=>"Potato", :price_cents=>8.2}], :total=>26.2}
157
+ # Hm(order).transform(%i[items * price] => %i[items * price_usd]) { |val| val / 100.0 }.to_h
158
+ # # => {:items=>[{:title=>"Beef", :price_usd=>0.18}, {:title=>"Potato", :price_usd=>0.082}], :total=>26.2}
159
+ # Hm(order).transform(%i[items *] => :*).to_h # copying them out
160
+ # # => {:items=>[], :total=>26.2, 0=>{:title=>"Beef", :price=>18.0}, 1=>{:title=>"Potato", :price=>8.2}}
161
+ #
162
+ # @see #transform_keys
163
+ # @see #transform_values
164
+ # @see #update
165
+ # @param keys_to_keys [Hash] Each key-value pair of input hash represents "source path to take
166
+ # values" => "target path to store values". Each can be single key or nested path,
167
+ # including `:*` wildcard.
168
+ # @param processor [Proc] Optional block to process value with while moving.
169
+ # @yieldparam value
170
+ # @return [self]
171
+ def transform(keys_to_keys, &processor)
172
+ keys_to_keys.each { |from, to| transform_one(Array(from), Array(to), &processor) }
173
+ self
174
+ end
175
+
176
+ # Like {#transform}, but copies values instead of moving them (original keys/values are preserved).
177
+ #
178
+ # @example
179
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
180
+ # Hm(order).update(%i[items * price] => %i[items * price_usd]) { |val| val / 100.0 }.to_h
181
+ # # => {:items=>[{:title=>"Beef", :price=>18.0, :price_usd=>0.18}, {:title=>"Potato", :price=>8.2, :price_usd=>0.082}], :total=>26.2}
182
+ #
183
+ # @see #transform_keys
184
+ # @see #transform_values
185
+ # @see #transform
186
+ # @param keys_to_keys [Hash] Each key-value pair of input hash represents "source path to take
187
+ # values" => "target path to store values". Each can be single key or nested path,
188
+ # including `:*` wildcard.
189
+ # @param processor [Proc] Optional block to process value with while copying.
190
+ # @yieldparam value
191
+ # @return [self]
192
+ def update(keys_to_keys, &processor)
193
+ keys_to_keys.each { |from, to| transform_one(Array(from), Array(to), remove: false, &processor) }
194
+ self
195
+ end
196
+
197
+ # Performs specified transformations on keys of input sequence, optionally limited only by specified
198
+ # pathes.
199
+ #
200
+ # Note that when `pathes` parameter is passed, only keys directly matching the pathes are processed,
201
+ # not entire sub-collection under this path.
202
+ #
203
+ # @example
204
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
205
+ # Hm(order).transform_keys(&:to_s).to_h
206
+ # # => {"items"=>[{"title"=>"Beef", "price"=>18.0}, {"title"=>"Potato", "price"=>8.2}], "total"=>26.2}
207
+ # Hm(order)
208
+ # .transform_keys(&:to_s)
209
+ # .transform_keys(['items', :*, :*], &:capitalize)
210
+ # .transform_keys(:*, &:upcase).to_h
211
+ # # => {"ITEMS"=>[{"Title"=>"Beef", "Price"=>18.0}, {"Title"=>"Potato", "Price"=>8.2}], "TOTAL"=>26.2}
212
+ #
213
+ # @see #transform_values
214
+ # @see #transform
215
+ # @param pathes [Array] List of pathes (each being singular key, or array of keys, including
216
+ # `:*` wildcard) to look at.
217
+ # @yieldparam key [Array] Current key to process.
218
+ # @return [self]
219
+ def transform_keys(*pathes)
220
+ if pathes.empty?
221
+ Algo.visit_all(@hash) do |at, path, val|
222
+ if at.is_a?(Hash)
223
+ at.delete(path.last)
224
+ at[yield(path.last)] = val
225
+ end
226
+ end
227
+ else
228
+ pathes.each do |path|
229
+ Algo.visit(@hash, path) do |at, pth, val|
230
+ Algo.delete(at, pth.last)
231
+ at[yield(pth.last)] = val
232
+ end
233
+ end
234
+ end
235
+ self
236
+ end
237
+
238
+ # Performs specified transformations on values of input sequence, limited only by specified
239
+ # pathes.
240
+ #
241
+ # @note
242
+ # Unlike {#transform_keys}, this method does nothing when no pathes are passed (e.g. not runs
243
+ # transformation on each value), because the semantic would be unclear. In our `:order` example,
244
+ # list of all items is a value _too_, at `:items` key, so should it be also transformed?
245
+ #
246
+ # @example
247
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
248
+ # Hm(order).transform_values(%i[items * price], :total, &:to_s).to_h
249
+ # # => {:items=>[{:title=>"Beef", :price=>"18.0"}, {:title=>"Potato", :price=>"8.2"}], :total=>"26.2"}
250
+ #
251
+ # @see #transform_values
252
+ # @see #transform
253
+ # @param pathes [Array] List of pathes (each being singular key, or array of keys, including
254
+ # `:*` wildcard) to look at.
255
+ # @yieldparam value [Array] Current value to process.
256
+ # @return [self]
257
+ def transform_values(*pathes)
258
+ pathes.each do |path|
259
+ Algo.visit(@hash, path) { |at, pth, val| at[pth.last] = yield(val) }
260
+ end
261
+ self
262
+ end
263
+
264
+ # Removes all specified pathes from input sequence.
265
+ #
266
+ # @example
267
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
268
+ # Hm(order).except(%i[items * title]).to_h
269
+ # # => {:items=>[{:price=>18.0}, {:price=>8.2}], :total=>26.2}
270
+ # Hm(order).except([:items, 0, :title], :total).to_h
271
+ # # => {:items=>[{:price=>18.0}, {:title=>"Potato", :price=>8.2}]}
272
+ #
273
+ # @see #slice
274
+ # @param pathes [Array] List of pathes (each being singular key, or array of keys, including
275
+ # `:*` wildcard) to look at.
276
+ # @return [self]
277
+ def except(*pathes)
278
+ pathes.each do |path|
279
+ Algo.visit(@hash, path) { |what, pth, _| Algo.delete(what, pth.last) }
280
+ end
281
+ self
282
+ end
283
+
284
+ # Preserves only specified pathes from input sequence.
285
+ #
286
+ # @example
287
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
288
+ # Hm(order).slice(%i[items * title]).to_h
289
+ # # => {:items=>[{:title=>"Beef"}, {:title=>"Potato"}]}
290
+ #
291
+ # @see #except
292
+ # @param pathes [Array] List of pathes (each being singular key, or array of keys, including
293
+ # `:*` wildcard) to look at.
294
+ # @return [self]
295
+ def slice(*pathes)
296
+ result = Hm.new({})
297
+ pathes.each do |path|
298
+ Algo.visit(@hash, path) { |_, new_path, val| result.bury(*new_path, val) }
299
+ end
300
+ @hash = result.to_h
301
+ self
302
+ end
303
+
304
+ # Removes all `nil` values, including nested structures.
305
+ #
306
+ # @example
307
+ # order = {items: [{title: "Beef", price: nil}, nil, {title: "Potato", price: 8.2}], total: 26.2}
308
+ # Hm(order).compact.to_h
309
+ # # => {:items=>[{:title=>"Beef"}, {:title=>"Potato", :price=>8.2}], :total=>26.2}
310
+ #
311
+ # @return [self]
312
+ def compact
313
+ Algo.visit_all(@hash) do |at, path, val|
314
+ Algo.delete(at, path.last) if val.nil?
315
+ end
316
+ end
317
+
318
+ # Removes all "empty" values and subcollections (`nil`s, empty strings, hashes and arrays),
319
+ # including nested structures. Empty subcollections are removed recoursively.
320
+ #
321
+ # @example
322
+ # order = {items: [{title: "Beef", price: 18.2}, {title: '', price: nil}], total: 26.2}
323
+ # Hm(order).cleanup.to_h
324
+ # # => {:items=>[{:title=>"Beef", :price=>18.2}], :total=>26.2}
325
+ #
326
+ # @return [self]
327
+ def cleanup
328
+ deletions = -1
329
+ # We do several runs to delete recursively: {a: {b: [nil]}}
330
+ # first: {a: {b: []}}
331
+ # second: {a: {}}
332
+ # third: {}
333
+ # More effective would be some "inside out" visiting, probably
334
+ until deletions.zero?
335
+ deletions = 0
336
+ Algo.visit_all(@hash) do |at, path, val|
337
+ if val.nil? || val.respond_to?(:empty?) && val.empty?
338
+ deletions += 1
339
+ Algo.delete(at, path.last)
340
+ end
341
+ end
342
+ end
343
+ self
344
+ end
345
+
346
+ # Select subset of the collection by provided block (optionally looking only at pathes specified).
347
+ #
348
+ # Method is added mostly for completeness, as filtering out wrong values is better done with
349
+ # {#reject}, and selecting just by subset of keys by {#slice} and {#except}.
350
+ #
351
+ # @example
352
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
353
+ # Hm(order).select { |path, val| val.is_a?(Float) }.to_h
354
+ # # => {:items=>[{:price=>18.0}, {:price=>8.2}], :total=>26.2}
355
+ # Hm(order).select([:items, :*, :price]) { |path, val| val > 10 }.to_h
356
+ # # => {:items=>[{:price=>18.0}]}
357
+ #
358
+ # @see #reject
359
+ # @param pathes [Array] List of pathes (each being singular key, or array of keys, including
360
+ # `:*` wildcard) to look at.
361
+ # @yieldparam path [Array] Current path at which the value is found
362
+ # @yieldparam value Current value
363
+ # @yieldreturn [true, false] Preserve value (with corresponding key) if true.
364
+ # @return [self]
365
+ def select(*pathes)
366
+ res = Hm.new({})
367
+ if pathes.empty?
368
+ Algo.visit_all(@hash) do |_, path, val|
369
+ res.bury(*path, val) if yield(path, val)
370
+ end
371
+ else
372
+ pathes.each do |path|
373
+ Algo.visit(@hash, path) do |_, pth, val|
374
+ res.bury(*pth, val) if yield(pth, val)
375
+ end
376
+ end
377
+ end
378
+ @hash = res.to_h
379
+ self
380
+ end
381
+
382
+ # Drops subset of the collection by provided block (optionally looking only at pathes specified).
383
+ #
384
+ # @example
385
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}], total: 26.2}
386
+ # Hm(order).reject { |path, val| val.is_a?(Float) && val < 10 }.to_h
387
+ # # => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato"}], :total=>26.2}
388
+ # Hm(order).reject(%i[items * price]) { |path, val| val < 10 }.to_h
389
+ # # => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato"}], :total=>26.2}
390
+ # Hm(order).reject(%i[items *]) { |path, val| val[:price] < 10 }.to_h
391
+ # # => {:items=>[{:title=>"Beef", :price=>18.0}], :total=>26.2}
392
+ #
393
+ # @see #select
394
+ # @param pathes [Array] List of pathes (each being singular key, or array of keys, including
395
+ # `:*` wildcard) to look at.
396
+ # @yieldparam path [Array] Current path at which the value is found
397
+ # @yieldparam value Current value
398
+ # @yieldreturn [true, false] Remove value (with corresponding key) if true.
399
+ # @return [self]
400
+ def reject(*pathes)
401
+ if pathes.empty?
402
+ Algo.visit_all(@hash) do |at, path, val|
403
+ Algo.delete(at, path.last) if yield(path, val)
404
+ end
405
+ else
406
+ pathes.each do |path|
407
+ Algo.visit(@hash, path) do |at, pth, val|
408
+ Algo.delete(at, pth.last) if yield(pth, val)
409
+ end
410
+ end
411
+ end
412
+ self
413
+ end
414
+
415
+ # Calculates one value from several values at specified pathes, using specified block.
416
+ #
417
+ # @example
418
+ # order = {items: [{title: "Beef", price: 18.0}, {title: "Potato", price: 8.2}]}
419
+ # Hm(order).reduce(%i[items * price] => :total, &:+).to_h
420
+ # # => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}], :total=>26.2}
421
+ # Hm(order).reduce(%i[items * price] => :total, %i[items * title] => :title, &:+).to_h
422
+ # # => {:items=>[{:title=>"Beef", :price=>18.0}, {:title=>"Potato", :price=>8.2}], :total=>26.2, :title=>"BeefPotato"}
423
+ #
424
+ # @param keys_to_keys [Hash] Each key-value pair of input hash represents "source path to take
425
+ # values" => "target path to store result of reduce". Each can be single key or nested path,
426
+ # including `:*` wildcard.
427
+ # @yieldparam memo
428
+ # @yieldparam value
429
+ # @return [self]
430
+ def reduce(keys_to_keys, &block)
431
+ keys_to_keys.each do |from, to|
432
+ bury(*to, dig(*from).reduce(&block))
433
+ end
434
+ self
435
+ end
436
+
437
+ # Returns the result of all the processings inside the `Hm` object.
438
+ #
439
+ # Note, that you can pass an Array as a top-level structure to `Hm`, and in this case `to_h` will
440
+ # return the processed Array... Not sure what to do about that currently.
441
+ #
442
+ # @return [Hash]
443
+ def to_h
444
+ @hash
445
+ end
446
+
447
+ alias to_hash to_h
448
+
449
+ private
450
+
451
+ def transform_one(from, to, remove: true, &_processor) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
452
+ to.count(:*) > 1 || from.count(:*) > 1 and
453
+ fail NotImplementedError, 'Transforming to multi-wildcards is not implemented'
454
+
455
+ from_values = {}
456
+ Algo.visit(
457
+ @hash, from,
458
+ # [:item, :*, :price] -- if one item is priceless, should go to output sequence
459
+ # ...but what if last key is :*? something like from_values.keys.last.succ or?..
460
+ not_found: ->(_, path, rest) { from_values[path + rest] = nil }
461
+ ) { |_, path, val| from_values[path] = block_given? ? yield(val) : val }
462
+ except(from) if remove
463
+ if (ti = to.index(:*))
464
+ fi = from.index(:*) # TODO: what if `from` had no wildcard?
465
+
466
+ # we unpack [:items, :*, :price] with keys we got from gathering values,
467
+ # like [:items, 0, :price], [:items, 1, :price] etc.
468
+ from_values
469
+ .map { |key, val| [to.dup.tap { |a| a[ti] = key[fi] }, val] }
470
+ .each { |path, val| bury(*path, val) }
471
+ else
472
+ val = from_values.count == 1 ? from_values.values.first : from_values.values
473
+ bury(*to, val)
474
+ end
475
+ end
476
+ end
477
+
478
+ require_relative 'hm/algo'
479
+
480
+ # Shortcut for {Hm}.new
481
+ def Hm(hash) # rubocop:disable Naming/MethodName
482
+ Hm.new(hash)
483
+ end
@@ -0,0 +1,95 @@
1
+ class Hm
2
+ # @private
3
+ module Algo
4
+ module_function
5
+
6
+ def delete(collection, key)
7
+ collection.is_a?(Array) ? collection.delete_at(key) : collection.delete(key)
8
+ end
9
+
10
+ # JRuby, I am looking at you
11
+ NONDUPABLE = [Symbol, Numeric, NilClass, TrueClass, FalseClass].freeze
12
+
13
+ def deep_copy(value)
14
+ # FIXME: ignores Struct/OpenStruct (which are diggable too)
15
+ case value
16
+ when Hash
17
+ value.map { |key, val| [key, deep_copy(val)] }.to_h
18
+ when Array
19
+ value.map(&method(:deep_copy))
20
+ when *NONDUPABLE
21
+ value
22
+ else
23
+ value.dup
24
+ end
25
+ end
26
+
27
+ def to_pairs(collection)
28
+ case
29
+ when collection.respond_to?(:each_pair)
30
+ collection.each_pair.to_a
31
+ when collection.respond_to?(:each)
32
+ collection.each_with_index.to_a.map(&:reverse)
33
+ else
34
+ fail TypeError, "Can't dig/* in #{collection.class}"
35
+ end
36
+ end
37
+
38
+ # Enumerates through entire collection with "current key/current values" at each point, even
39
+ # if elements are deleted in a process of enumeration
40
+ def robust_enumerator(collection)
41
+ return to_pairs(collection) if collection.is_a?(Hash)
42
+
43
+ # Only Arrays need this kind of trickery
44
+ Enumerator.new do |y|
45
+ cur = collection.size
46
+ until cur.zero?
47
+ pairs = to_pairs(collection)
48
+ pos = pairs.size - cur
49
+ y << pairs[pos]
50
+ cur -= 1
51
+ end
52
+ end
53
+ end
54
+
55
+ def nest_hashes(value, *keys)
56
+ return value if keys.empty?
57
+ key = keys.shift
58
+ val = keys.empty? ? value : nest_hashes(value, *keys)
59
+ key.is_a?(Integer) ? [].tap { |arr| arr[key] = val } : {key => val}
60
+ end
61
+
62
+ def visit(what, rest, path = [], not_found: ->(*) {}, &found)
63
+ what.respond_to?(:dig) or fail TypeError, "#{what.class} is not diggable"
64
+
65
+ key, *rst = rest
66
+ if key == WILDCARD
67
+ visit_wildcard(what, rst, path, found: found, not_found: not_found)
68
+ else
69
+ visit_regular(what, key, rst, path, found: found, not_found: not_found)
70
+ end
71
+ end
72
+
73
+ def visit_all(what, path = [], &block)
74
+ robust_enumerator(what).each do |key, val|
75
+ yield(what, [*path, key], val)
76
+ visit_all(val, [*path, key], &block) if val.respond_to?(:dig)
77
+ end
78
+ end
79
+
80
+ def visit_wildcard(what, rest, path, found:, not_found:)
81
+ iterator = robust_enumerator(what)
82
+ if rest.empty?
83
+ iterator.map { |key, val| found.(what, [*path, key], val) }
84
+ else
85
+ iterator.map { |key, el| visit(el, rest, [*path, key], not_found: not_found, &found) }
86
+ end
87
+ end
88
+
89
+ def visit_regular(what, key, rest, path, found:, not_found:) # rubocop:disable Metrics/ParameterLists
90
+ internal = what.dig(key) or return not_found.(what, [*path, key], rest)
91
+ rest.empty? and return found.(what, [*path, key], internal)
92
+ visit(internal, rest, [*path, key], not_found: not_found, &found)
93
+ end
94
+ end
95
+ end
metadata ADDED
@@ -0,0 +1,218 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Victor Shepelev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-02-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: yard
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redcarpet
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: github-markup
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard-junk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 3.7.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 3.7.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-its
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: saharspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rspec
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rake
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubygems-tasks
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: benchmark-ips
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ description: |2
182
+ Hm is a library for clean, idiomatic and chainable processing of complicated Ruby structures,
183
+ typically unpacked from JSON. It provides smart dig and bury, keys replacement,
184
+ nested transformations and more.
185
+ email: zverok.offline@gmail.com
186
+ executables: []
187
+ extensions: []
188
+ extra_rdoc_files: []
189
+ files:
190
+ - LICENSE.txt
191
+ - README.md
192
+ - lib/hm.rb
193
+ - lib/hm/algo.rb
194
+ homepage: https://github.com/zverok/hm
195
+ licenses:
196
+ - MIT
197
+ metadata: {}
198
+ post_install_message:
199
+ rdoc_options: []
200
+ require_paths:
201
+ - lib
202
+ required_ruby_version: !ruby/object:Gem::Requirement
203
+ requirements:
204
+ - - ">="
205
+ - !ruby/object:Gem::Version
206
+ version: 2.3.0
207
+ required_rubygems_version: !ruby/object:Gem::Requirement
208
+ requirements:
209
+ - - ">="
210
+ - !ruby/object:Gem::Version
211
+ version: '0'
212
+ requirements: []
213
+ rubyforge_project:
214
+ rubygems_version: 2.6.14
215
+ signing_key:
216
+ specification_version: 4
217
+ summary: Idiomatic nested hash modifications
218
+ test_files: []