dry-transformer 0.1.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.
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/coercions'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Transformation functions for Hash objects
8
+ #
9
+ # @example
10
+ # require 'dry/transformer/hash'
11
+ #
12
+ # include Dry::Transformer::Helper
13
+ #
14
+ # fn = t(:symbolize_keys) >> t(:nest, :address, [:street, :zipcode])
15
+ #
16
+ # fn["street" => "Street 1", "zipcode" => "123"]
17
+ # # => {:address => {:street => "Street 1", :zipcode => "123"}}
18
+ #
19
+ # @api public
20
+ module HashTransformations
21
+ extend Registry
22
+
23
+ if RUBY_VERSION >= '2.5'
24
+ # Map all keys in a hash with the provided transformation function
25
+ #
26
+ # @example
27
+ # Dry::Transformer(:map_keys, -> s { s.upcase })['name' => 'Jane']
28
+ # # => {"NAME" => "Jane"}
29
+ #
30
+ # @param [Hash]
31
+ #
32
+ # @return [Hash]
33
+ #
34
+ # @api public
35
+ def self.map_keys(source_hash, fn)
36
+ Hash[source_hash].transform_keys!(&fn)
37
+ end
38
+ else
39
+ def self.map_keys(source_hash, fn)
40
+ Hash[source_hash].tap do |hash|
41
+ hash.keys.each { |key| hash[fn[key]] = hash.delete(key) }
42
+ end
43
+ end
44
+ end
45
+
46
+ # Symbolize all keys in a hash
47
+ #
48
+ # @example
49
+ # Dry::Transformer(:symbolize_keys)['name' => 'Jane']
50
+ # # => {:name => "Jane"}
51
+ #
52
+ # @param [Hash]
53
+ #
54
+ # @return [Hash]
55
+ #
56
+ # @api public
57
+ def self.symbolize_keys(hash)
58
+ map_keys(hash, Coercions[:to_symbol].fn)
59
+ end
60
+
61
+ # Symbolize keys in a hash recursively
62
+ #
63
+ # @example
64
+ #
65
+ # input = { 'foo' => 'bar', 'baz' => [{ 'one' => 1 }] }
66
+ #
67
+ # t(:deep_symbolize_keys)[input]
68
+ # # => { :foo => "bar", :baz => [{ :one => 1 }] }
69
+ #
70
+ # @param [Hash]
71
+ #
72
+ # @return [Hash]
73
+ #
74
+ # @api public
75
+ def self.deep_symbolize_keys(hash)
76
+ hash.each_with_object({}) do |(key, value), output|
77
+ output[key.to_sym] =
78
+ case value
79
+ when Hash
80
+ deep_symbolize_keys(value)
81
+ when Array
82
+ value.map { |item|
83
+ item.is_a?(Hash) ? deep_symbolize_keys(item) : item
84
+ }
85
+ else
86
+ value
87
+ end
88
+ end
89
+ end
90
+
91
+ # Stringify all keys in a hash
92
+ #
93
+ # @example
94
+ # Dry::Transformer(:stringify_keys)[:name => 'Jane']
95
+ # # => {"name" => "Jane"}
96
+ #
97
+ # @param [Hash]
98
+ #
99
+ # @return [Hash]
100
+ #
101
+ # @api public
102
+ def self.stringify_keys(hash)
103
+ map_keys(hash, Coercions[:to_string].fn)
104
+ end
105
+
106
+ # Stringify keys in a hash recursively
107
+ #
108
+ # @example
109
+ # input = { :foo => "bar", :baz => [{ :one => 1 }] }
110
+ #
111
+ # t(:deep_stringify_keys)[input]
112
+ # # => { "foo" => "bar", "baz" => [{ "one" => 1 }] }
113
+ #
114
+ # @param [Hash]
115
+ #
116
+ # @return [Hash]
117
+ #
118
+ # @api public
119
+ def self.deep_stringify_keys(hash)
120
+ hash.each_with_object({}) do |(key, value), output|
121
+ output[key.to_s] =
122
+ case value
123
+ when Hash
124
+ deep_stringify_keys(value)
125
+ when Array
126
+ value.map { |item|
127
+ item.is_a?(Hash) ? deep_stringify_keys(item) : item
128
+ }
129
+ else
130
+ value
131
+ end
132
+ end
133
+ end
134
+
135
+ if RUBY_VERSION >= '2.4'
136
+ # Map all values in a hash using transformation function
137
+ #
138
+ # @example
139
+ # Dry::Transformer(:map_values, -> v { v.upcase })[:name => 'Jane']
140
+ # # => {"name" => "JANE"}
141
+ #
142
+ # @param [Hash]
143
+ #
144
+ # @return [Hash]
145
+ #
146
+ # @api public
147
+ def self.map_values(source_hash, fn)
148
+ Hash[source_hash].transform_values!(&fn)
149
+ end
150
+ else
151
+ def self.map_values(source_hash, fn)
152
+ Hash[source_hash].tap do |hash|
153
+ hash.each { |key, value| hash[key] = fn[value] }
154
+ end
155
+ end
156
+ end
157
+
158
+ # Rename all keys in a hash using provided mapping hash
159
+ #
160
+ # @example
161
+ # Dry::Transformer(:rename_keys, user_name: :name)[user_name: 'Jane']
162
+ # # => {:name => "Jane"}
163
+ #
164
+ # @param [Hash] source_hash The input hash
165
+ # @param [Hash] mapping The key-rename mapping
166
+ #
167
+ # @return [Hash]
168
+ #
169
+ # @api public
170
+ def self.rename_keys(source_hash, mapping)
171
+ Hash[source_hash].tap do |hash|
172
+ mapping.each { |k, v| hash[v] = hash.delete(k) if hash.key?(k) }
173
+ end
174
+ end
175
+
176
+ # Copy all keys in a hash using provided mapping hash
177
+ #
178
+ # @example
179
+ # Dry::Transformer(:copy_keys, user_name: :name)[user_name: 'Jane']
180
+ # # => {:user_name => "Jane", :name => "Jane"}
181
+ #
182
+ # @param [Hash] source_hash The input hash
183
+ # @param [Hash] mapping The key-copy mapping
184
+ #
185
+ # @return [Hash]
186
+ #
187
+ # @api public
188
+ def self.copy_keys(source_hash, mapping)
189
+ Hash[source_hash].tap do |hash|
190
+ mapping.each do |original_key, new_keys|
191
+ [*new_keys].each do |new_key|
192
+ hash[new_key] = hash[original_key]
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ # Rejects specified keys from a hash
199
+ #
200
+ # @example
201
+ # Dry::Transformer(:reject_keys, [:name])[name: 'Jane', email: 'jane@doe.org']
202
+ # # => {:email => "jane@doe.org"}
203
+ #
204
+ # @param [Hash] hash The input hash
205
+ # @param [Array] keys The keys to be rejected
206
+ #
207
+ # @return [Hash]
208
+ #
209
+ # @api public
210
+ def self.reject_keys(hash, keys)
211
+ Hash[hash].reject { |k, _| keys.include?(k) }
212
+ end
213
+
214
+ if RUBY_VERSION >= '2.5'
215
+ # Accepts specified keys from a hash
216
+ #
217
+ # @example
218
+ # Dry::Transformer(:accept_keys, [:name])[name: 'Jane', email: 'jane@doe.org']
219
+ # # => {:name=>"Jane"}
220
+ #
221
+ # @param [Hash] hash The input hash
222
+ # @param [Array] keys The keys to be accepted
223
+ #
224
+ # @return [Hash]
225
+ #
226
+ # @api public
227
+ def self.accept_keys(hash, keys)
228
+ Hash[hash].slice(*keys)
229
+ end
230
+ else
231
+ def self.accept_keys(hash, keys)
232
+ reject_keys(hash, hash.keys - keys)
233
+ end
234
+ end
235
+
236
+ # Map a key in a hash with the provided transformation function
237
+ #
238
+ # @example
239
+ # Dry::Transformer(:map_value, 'name', -> s { s.upcase })['name' => 'jane']
240
+ # # => {"name" => "JANE"}
241
+ #
242
+ # @param [Hash]
243
+ #
244
+ # @return [Hash]
245
+ #
246
+ # @api public
247
+ def self.map_value(hash, key, fn)
248
+ hash.merge(key => fn[hash[key]])
249
+ end
250
+
251
+ # Nest values from specified keys under a new key
252
+ #
253
+ # @example
254
+ # Dry::Transformer(:nest, :address, [:street, :zipcode])[street: 'Street', zipcode: '123']
255
+ # # => {address: {street: "Street", zipcode: "123"}}
256
+ #
257
+ # @param [Hash]
258
+ #
259
+ # @return [Hash]
260
+ #
261
+ # @api public
262
+ def self.nest(hash, root, keys)
263
+ child = {}
264
+
265
+ keys.each do |key|
266
+ child[key] = hash[key] if hash.key?(key)
267
+ end
268
+
269
+ output = Hash[hash]
270
+
271
+ child.each_key { |key| output.delete(key) }
272
+
273
+ old_root = hash[root]
274
+
275
+ if old_root.is_a?(Hash)
276
+ output[root] = old_root.merge(child)
277
+ else
278
+ output[root] = child
279
+ end
280
+
281
+ output
282
+ end
283
+
284
+ # Collapse a nested hash from a specified key
285
+ #
286
+ # @example
287
+ # Dry::Transformer(:unwrap, :address, [:street, :zipcode])[address: { street: 'Street', zipcode: '123' }]
288
+ # # => {street: "Street", zipcode: "123"}
289
+ #
290
+ # @param [Hash] source_hash
291
+ # @param [Mixed] root The root key to unwrap values from
292
+ # @param [Array] selected The keys that should be unwrapped (optional)
293
+ # @param [Hash] options hash of options (optional)
294
+ # @option options [Boolean] :prefix if true, unwrapped keys will be prefixed
295
+ # with the root key followed by an underscore (_)
296
+ #
297
+ # @return [Hash]
298
+ #
299
+ # @api public
300
+ def self.unwrap(source_hash, root, selected = nil, prefix: false)
301
+ return source_hash unless source_hash[root]
302
+
303
+ add_prefix = lambda do |key|
304
+ combined = [root, key].join('_')
305
+ root.is_a?(::Symbol) ? combined.to_sym : combined
306
+ end
307
+
308
+ Hash[source_hash].merge(root => Hash[source_hash[root]]).tap do |hash|
309
+ nested_hash = hash[root]
310
+ keys = nested_hash.keys
311
+ keys &= selected if selected
312
+ new_keys = prefix ? keys.map(&add_prefix) : keys
313
+ nested_contains_root_key = nested_hash.key?(root)
314
+
315
+ hash.update(Hash[new_keys.zip(keys.map { |key| nested_hash.delete(key) })])
316
+ hash.delete(root) if nested_hash.empty? && !nested_contains_root_key
317
+ end
318
+ end
319
+
320
+ # Folds array of tuples to array of values from a specified key
321
+ #
322
+ # @example
323
+ # source = {
324
+ # name: "Jane",
325
+ # tasks: [{ title: "be nice", priority: 1 }, { title: "sleep well" }]
326
+ # }
327
+ # Dry::Transformer(:fold, :tasks, :title)[source]
328
+ # # => { name: "Jane", tasks: ["be nice", "sleep well"] }
329
+ # Dry::Transformer(:fold, :tasks, :priority)[source]
330
+ # # => { name: "Jane", tasks: [1, nil] }
331
+ #
332
+ # @param [Hash] hash
333
+ # @param [Object] key The key to fold values to
334
+ # @param [Object] tuple_key The key to take folded values from
335
+ #
336
+ # @return [Hash]
337
+ #
338
+ # @api public
339
+ def self.fold(hash, key, tuple_key)
340
+ hash.merge(key => ArrayTransformations.extract_key(hash[key], tuple_key))
341
+ end
342
+
343
+ # Splits hash to array by all values from a specified key
344
+ #
345
+ # The operation adds missing keys extracted from the array to regularize the output.
346
+ #
347
+ # @example
348
+ # input = {
349
+ # name: 'Joe',
350
+ # tasks: [
351
+ # { title: 'sleep well', priority: 1 },
352
+ # { title: 'be nice', priority: 2 },
353
+ # { priority: 2 },
354
+ # { title: 'be cool' }
355
+ # ]
356
+ # }
357
+ # Dry::Transformer(:split, :tasks, [:priority])[input]
358
+ # => [
359
+ # { name: 'Joe', priority: 1, tasks: [{ title: 'sleep well' }] },
360
+ # { name: 'Joe', priority: 2, tasks: [{ title: 'be nice' }, { title: nil }] },
361
+ # { name: 'Joe', priority: nil, tasks: [{ title: 'be cool' }] }
362
+ # ]
363
+ #
364
+ # @param [Hash] hash
365
+ # @param [Object] key The key to split a hash by
366
+ # @param [Array] subkeys The list of subkeys to be extracted from key
367
+ #
368
+ # @return [Array<Hash>]
369
+ #
370
+ # @api public
371
+ def self.split(hash, key, keys)
372
+ list = Array(hash[key])
373
+ return [hash.reject { |k, _| k == key }] if list.empty?
374
+
375
+ existing = list.flat_map(&:keys).uniq
376
+ grouped = existing - keys
377
+ ungrouped = existing & keys
378
+
379
+ list = ArrayTransformations.group(list, key, grouped) if grouped.any?
380
+ list = list.map { |item| item.merge(reject_keys(hash, [key])) }
381
+ ArrayTransformations.add_keys(list, ungrouped)
382
+ end
383
+
384
+ # Recursively evaluate hash values if they are procs/lambdas
385
+ #
386
+ # @example
387
+ # hash = {
388
+ # num: -> i { i + 1 },
389
+ # str: -> i { "num #{i}" }
390
+ # }
391
+ #
392
+ # t(:eval_values, 1)[hash]
393
+ # # => {:num => 2, :str => "num 1" }
394
+ #
395
+ # # with filters
396
+ # t(:eval_values, 1, [:str])[hash]
397
+ # # => {:num => #{still a proc}, :str => "num 1" }
398
+ #
399
+ # @param [Hash]
400
+ # @param [Array,Object] args Anything that should be passed to procs
401
+ # @param [Array] filters A list of attribute names that should be evaluated
402
+ #
403
+ # @api public
404
+ def self.eval_values(hash, args, filters = [])
405
+ hash.each_with_object({}) do |(key, value), output|
406
+ output[key] =
407
+ case value
408
+ when Proc
409
+ if filters.empty? || filters.include?(key)
410
+ value.call(*args)
411
+ else
412
+ value
413
+ end
414
+ when Hash
415
+ eval_values(value, args, filters)
416
+ when Array
417
+ value.map { |item|
418
+ item.is_a?(Hash) ? eval_values(item, args, filters) : item
419
+ }
420
+ else
421
+ value
422
+ end
423
+ end
424
+ end
425
+
426
+ # Merge a hash recursively
427
+ #
428
+ # @example
429
+ #
430
+ # input = { 'foo' => 'bar', 'baz' => { 'one' => 1 } }
431
+ # other = { 'foo' => 'buz', 'baz' => { :one => 'one', :two => 2 } }
432
+ #
433
+ # t(:deep_merge)[input, other]
434
+ # # => { 'foo' => "buz", :baz => { :one => 'one', 'one' => 1, :two => 2 } }
435
+ #
436
+ # @param [Hash]
437
+ # @param [Hash]
438
+ #
439
+ # @return [Hash]
440
+ #
441
+ # @api public
442
+ def self.deep_merge(hash, other)
443
+ Hash[hash].merge(other) do |_, original_value, new_value|
444
+ if original_value.respond_to?(:to_hash) &&
445
+ new_value.respond_to?(:to_hash)
446
+ deep_merge(Hash[original_value], Hash[new_value])
447
+ else
448
+ new_value
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end