dry-transformer 0.1.1

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