hm 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: []