dry-transformer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +12 -0
  3. data/.github/ISSUE_TEMPLATE/----please-don-t-ask-for-support-via-issues.md +10 -0
  4. data/.github/ISSUE_TEMPLATE/---bug-report.md +30 -0
  5. data/.github/ISSUE_TEMPLATE/---feature-request.md +18 -0
  6. data/.github/workflows/custom_ci.yml +66 -0
  7. data/.github/workflows/docsite.yml +34 -0
  8. data/.github/workflows/sync_configs.yml +34 -0
  9. data/.gitignore +16 -0
  10. data/.rspec +4 -0
  11. data/.rubocop.yml +95 -0
  12. data/CHANGELOG.md +3 -0
  13. data/CODE_OF_CONDUCT.md +13 -0
  14. data/CONTRIBUTING.md +29 -0
  15. data/Gemfile +19 -0
  16. data/LICENSE +20 -0
  17. data/README.md +29 -0
  18. data/Rakefile +6 -0
  19. data/docsite/source/built-in-transformations.html.md +47 -0
  20. data/docsite/source/index.html.md +15 -0
  21. data/docsite/source/transformation-objects.html.md +32 -0
  22. data/docsite/source/using-standalone-functions.html.md +82 -0
  23. data/dry-transformer.gemspec +22 -0
  24. data/lib/dry-transformer.rb +3 -0
  25. data/lib/dry/transformer.rb +23 -0
  26. data/lib/dry/transformer/all.rb +11 -0
  27. data/lib/dry/transformer/array.rb +183 -0
  28. data/lib/dry/transformer/array/combine.rb +65 -0
  29. data/lib/dry/transformer/class.rb +56 -0
  30. data/lib/dry/transformer/coercions.rb +196 -0
  31. data/lib/dry/transformer/compiler.rb +47 -0
  32. data/lib/dry/transformer/composite.rb +54 -0
  33. data/lib/dry/transformer/conditional.rb +76 -0
  34. data/lib/dry/transformer/constants.rb +7 -0
  35. data/lib/dry/transformer/error.rb +16 -0
  36. data/lib/dry/transformer/function.rb +109 -0
  37. data/lib/dry/transformer/hash.rb +453 -0
  38. data/lib/dry/transformer/pipe.rb +75 -0
  39. data/lib/dry/transformer/pipe/class_interface.rb +115 -0
  40. data/lib/dry/transformer/pipe/dsl.rb +58 -0
  41. data/lib/dry/transformer/proc.rb +46 -0
  42. data/lib/dry/transformer/recursion.rb +121 -0
  43. data/lib/dry/transformer/registry.rb +150 -0
  44. data/lib/dry/transformer/store.rb +128 -0
  45. data/lib/dry/transformer/version.rb +7 -0
  46. data/spec/spec_helper.rb +31 -0
  47. data/spec/unit/array/combine_spec.rb +224 -0
  48. data/spec/unit/array_transformations_spec.rb +233 -0
  49. data/spec/unit/class_transformations_spec.rb +50 -0
  50. data/spec/unit/coercions_spec.rb +132 -0
  51. data/spec/unit/conditional_spec.rb +48 -0
  52. data/spec/unit/function_not_found_error_spec.rb +12 -0
  53. data/spec/unit/function_spec.rb +193 -0
  54. data/spec/unit/hash_transformations_spec.rb +490 -0
  55. data/spec/unit/proc_transformations_spec.rb +20 -0
  56. data/spec/unit/recursion_spec.rb +145 -0
  57. data/spec/unit/registry_spec.rb +202 -0
  58. data/spec/unit/store_spec.rb +198 -0
  59. data/spec/unit/transformer/class_interface_spec.rb +350 -0
  60. data/spec/unit/transformer/dsl_spec.rb +15 -0
  61. data/spec/unit/transformer/instance_methods_spec.rb +25 -0
  62. metadata +119 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/composite'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Transformation proc wrapper allowing composition of multiple procs into
8
+ # a data-transformation pipeline.
9
+ #
10
+ # This is used by Dry::Transformer to wrap registered methods.
11
+ #
12
+ # @api private
13
+ class Function
14
+ # Wrapped proc or another composite function
15
+ #
16
+ # @return [Proc,Composed]
17
+ #
18
+ # @api private
19
+ attr_reader :fn
20
+
21
+ # Additional arguments that will be passed to the wrapped proc
22
+ #
23
+ # @return [Array]
24
+ #
25
+ # @api private
26
+ attr_reader :args
27
+
28
+ # @!attribute [r] name
29
+ #
30
+ # @return [<type] The name of the function
31
+ #
32
+ # @api public
33
+ attr_reader :name
34
+
35
+ # @api private
36
+ def initialize(fn, options = {})
37
+ @fn = fn
38
+ @args = options.fetch(:args, [])
39
+ @name = options.fetch(:name, fn)
40
+ end
41
+
42
+ # Call the wrapped proc
43
+ #
44
+ # @param [Object] value The input value
45
+ #
46
+ # @alias []
47
+ #
48
+ # @api public
49
+ def call(*value)
50
+ fn.call(*value, *args)
51
+ end
52
+ alias_method :[], :call
53
+
54
+ # Compose this function with another function or a proc
55
+ #
56
+ # @param [Proc,Function]
57
+ #
58
+ # @return [Composite]
59
+ #
60
+ # @alias :>>
61
+ #
62
+ # @api public
63
+ def compose(other)
64
+ Composite.new(self, other)
65
+ end
66
+ alias_method :+, :compose
67
+ alias_method :>>, :compose
68
+
69
+ # Return a new fn with curried args
70
+ #
71
+ # @return [Function]
72
+ #
73
+ # @api private
74
+ def with(*args)
75
+ self.class.new(fn, name: name, args: args)
76
+ end
77
+
78
+ # @api public
79
+ def ==(other)
80
+ return false unless other.instance_of?(self.class)
81
+
82
+ [fn, name, args] == [other.fn, other.name, other.args]
83
+ end
84
+ alias_method :eql?, :==
85
+
86
+ # Return a simple AST representation of this function
87
+ #
88
+ # @return [Array]
89
+ #
90
+ # @api public
91
+ def to_ast
92
+ args_ast = args.map { |arg| arg.respond_to?(:to_ast) ? arg.to_ast : arg }
93
+ [name, args_ast]
94
+ end
95
+
96
+ # Converts a transproc to a simple proc
97
+ #
98
+ # @return [Proc]
99
+ #
100
+ def to_proc
101
+ if !args.empty?
102
+ proc { |*value| fn.call(*value, *args) }
103
+ else
104
+ fn.to_proc
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,453 @@
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
+
314
+ hash.update(Hash[new_keys.zip(keys.map { |key| nested_hash.delete(key) })])
315
+ hash.delete(root) if nested_hash.empty?
316
+ end
317
+ end
318
+
319
+ # Folds array of tuples to array of values from a specified key
320
+ #
321
+ # @example
322
+ # source = {
323
+ # name: "Jane",
324
+ # tasks: [{ title: "be nice", priority: 1 }, { title: "sleep well" }]
325
+ # }
326
+ # Dry::Transformer(:fold, :tasks, :title)[source]
327
+ # # => { name: "Jane", tasks: ["be nice", "sleep well"] }
328
+ # Dry::Transformer(:fold, :tasks, :priority)[source]
329
+ # # => { name: "Jane", tasks: [1, nil] }
330
+ #
331
+ # @param [Hash] hash
332
+ # @param [Object] key The key to fold values to
333
+ # @param [Object] tuple_key The key to take folded values from
334
+ #
335
+ # @return [Hash]
336
+ #
337
+ # @api public
338
+ def self.fold(hash, key, tuple_key)
339
+ hash.merge(key => ArrayTransformations.extract_key(hash[key], tuple_key))
340
+ end
341
+
342
+ # Splits hash to array by all values from a specified key
343
+ #
344
+ # The operation adds missing keys extracted from the array to regularize the output.
345
+ #
346
+ # @example
347
+ # input = {
348
+ # name: 'Joe',
349
+ # tasks: [
350
+ # { title: 'sleep well', priority: 1 },
351
+ # { title: 'be nice', priority: 2 },
352
+ # { priority: 2 },
353
+ # { title: 'be cool' }
354
+ # ]
355
+ # }
356
+ # Dry::Transformer(:split, :tasks, [:priority])[input]
357
+ # => [
358
+ # { name: 'Joe', priority: 1, tasks: [{ title: 'sleep well' }] },
359
+ # { name: 'Joe', priority: 2, tasks: [{ title: 'be nice' }, { title: nil }] },
360
+ # { name: 'Joe', priority: nil, tasks: [{ title: 'be cool' }] }
361
+ # ]
362
+ #
363
+ # @param [Hash] hash
364
+ # @param [Object] key The key to split a hash by
365
+ # @param [Array] subkeys The list of subkeys to be extracted from key
366
+ #
367
+ # @return [Array<Hash>]
368
+ #
369
+ # @api public
370
+ def self.split(hash, key, keys)
371
+ list = Array(hash[key])
372
+ return [hash.reject { |k, _| k == key }] if list.empty?
373
+
374
+ existing = list.flat_map(&:keys).uniq
375
+ grouped = existing - keys
376
+ ungrouped = existing & keys
377
+
378
+ list = ArrayTransformations.group(list, key, grouped) if grouped.any?
379
+ list = list.map { |item| item.merge(reject_keys(hash, [key])) }
380
+ ArrayTransformations.add_keys(list, ungrouped)
381
+ end
382
+
383
+ # Recursively evaluate hash values if they are procs/lambdas
384
+ #
385
+ # @example
386
+ # hash = {
387
+ # num: -> i { i + 1 },
388
+ # str: -> i { "num #{i}" }
389
+ # }
390
+ #
391
+ # t(:eval_values, 1)[hash]
392
+ # # => {:num => 2, :str => "num 1" }
393
+ #
394
+ # # with filters
395
+ # t(:eval_values, 1, [:str])[hash]
396
+ # # => {:num => #{still a proc}, :str => "num 1" }
397
+ #
398
+ # @param [Hash]
399
+ # @param [Array,Object] args Anything that should be passed to procs
400
+ # @param [Array] filters A list of attribute names that should be evaluated
401
+ #
402
+ # @api public
403
+ def self.eval_values(hash, args, filters = [])
404
+ hash.each_with_object({}) do |(key, value), output|
405
+ output[key] =
406
+ case value
407
+ when Proc
408
+ if filters.empty? || filters.include?(key)
409
+ value.call(*args)
410
+ else
411
+ value
412
+ end
413
+ when Hash
414
+ eval_values(value, args, filters)
415
+ when Array
416
+ value.map { |item|
417
+ item.is_a?(Hash) ? eval_values(item, args, filters) : item
418
+ }
419
+ else
420
+ value
421
+ end
422
+ end
423
+ end
424
+
425
+ # Merge a hash recursively
426
+ #
427
+ # @example
428
+ #
429
+ # input = { 'foo' => 'bar', 'baz' => { 'one' => 1 } }
430
+ # other = { 'foo' => 'buz', 'baz' => { :one => 'one', :two => 2 } }
431
+ #
432
+ # t(:deep_merge)[input, other]
433
+ # # => { 'foo' => "buz", :baz => { :one => 'one', 'one' => 1, :two => 2 } }
434
+ #
435
+ # @param [Hash]
436
+ # @param [Hash]
437
+ #
438
+ # @return [Hash]
439
+ #
440
+ # @api public
441
+ def self.deep_merge(hash, other)
442
+ Hash[hash].merge(other) do |_, original_value, new_value|
443
+ if original_value.respond_to?(:to_hash) &&
444
+ new_value.respond_to?(:to_hash)
445
+ deep_merge(Hash[original_value], Hash[new_value])
446
+ else
447
+ new_value
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end