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,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ task default: :spec
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Built-in transformation
3
+ layout: gem-single
4
+ name: dry-transformer
5
+ ---
6
+
7
+ `dry-transformer` comes with a lot of built-in functions. They come in the form of modules with class methods, which you can import into a registry:
8
+
9
+ * [Coercions](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/Coercions)
10
+ * [Array transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/ArrayTransformations)
11
+ * [Hash transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/HashTransformations)
12
+ * [Class transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/ClassTransformations)
13
+ * [Proc transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/ProcTransformations)
14
+ * [Conditional](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/Conditional)
15
+ * [Recursion](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/Recursion)
16
+
17
+ You can import everything with:
18
+
19
+ ```ruby
20
+ module T
21
+ extend Dry::Transformer::Registry
22
+
23
+ import Dry::Transformer::Coercions
24
+ import Dry::Transformer::ArrayTransformations
25
+ import Dry::Transformer::HashTransformations
26
+ import Dry::Transformer::ClassTransformations
27
+ import Dry::Transformer::ProcTransformations
28
+ import Dry::Transformer::Conditional
29
+ import Dry::Transformer::Recursion
30
+ end
31
+
32
+ T[:to_string].(:abc) # => 'abc'
33
+ ```
34
+
35
+ Or import selectively with:
36
+
37
+ ```ruby
38
+ module T
39
+ extend Dry::Transformer::Registry
40
+
41
+ import :to_string, from: Dry::Transformer::Coercions, as: :stringify
42
+ end
43
+
44
+ T[:stringify].(:abc) # => 'abc'
45
+ T[:to_string].(:abc)
46
+ # => Dry::Transformer::FunctionNotFoundError: No registered function T[:to_string]
47
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ title: Introduction
3
+ description: Data transformation toolkit
4
+ layout: gem-single
5
+ type: gem
6
+ name: dry-transformer
7
+ sections:
8
+ - transformation-objects
9
+ - built-in-transformations
10
+ - using-standalone-functions
11
+ ---
12
+
13
+ dry-transformer is a library that allows you to compose procs into a functional pipeline using left-to-right function composition.
14
+
15
+ The approach came from Functional Programming, where simple functions are composed into more complex functions in order to transform some data. It works like `|>` in Elixir or `>>` in F#. dry-transformer provides a mechanism to define and compose transformations, along with a number of built-in transformations.
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: Transformation objects
3
+ name: dry-transformer
4
+ layout: gem-single
5
+ ---
6
+
7
+ You can define transformation classes using the DSL which converts every method call to its corresponding transformation, and composes these transformations into a transformation pipeline. Here's a simple example where the default registry is used:
8
+
9
+ ```ruby
10
+ class MyMapper < Dry::Transformer[Dry::Transformer::Registry]
11
+ define! do
12
+ map_array do
13
+ symbolize_keys
14
+ rename_keys user_name: :name
15
+ nest :address, [:city, :street, :zipcode]
16
+ end
17
+ end
18
+ end
19
+
20
+ mapper = MyMapper.new
21
+
22
+ mapper.(
23
+ [
24
+ { 'user_name' => 'Jane',
25
+ 'city' => 'NYC',
26
+ 'street' => 'Street 1',
27
+ 'zipcode' => '123'
28
+ }
29
+ ]
30
+ )
31
+ # => [{:name=>"Jane", :address=>{:city=>"NYC", :street=>"Street 1", :zipcode=>"123"}}]
32
+ ```
@@ -0,0 +1,82 @@
1
+ ---
2
+ title: Using standalone functions
3
+ name: dry-transformer
4
+ layout: gem-single
5
+ ---
6
+
7
+ You can use `dry-transformer` and its function registry feature stand-alone, without the need to define transformation classes. To do so, simply define a module and extend it with the registry API:
8
+
9
+ ``` ruby
10
+ require 'json'
11
+ require 'dry/transformer/all'
12
+
13
+ # create your own local registry for transformation functions
14
+ module Functions
15
+ extend Dry::Transformer::Registry
16
+ end
17
+
18
+ # import necessary functions from other transprocs...
19
+ module Functions
20
+ # import all singleton methods from a module/class
21
+ import Dry::Transformer::HashTransformations
22
+ import Dry::Transformer::ArrayTransformations
23
+ end
24
+
25
+ # ...or from any external library
26
+ require 'dry-inflector'
27
+
28
+ Inflector = Dry::Inflector.new
29
+
30
+ module Functions
31
+ # import only necessary singleton methods from a module/class
32
+ # and rename them locally
33
+ import :camelize, from: Inflector, as: :camel_case
34
+ end
35
+
36
+ def t(*args)
37
+ Functions[*args]
38
+ end
39
+
40
+ # use imported transformation
41
+ transformation = t(:camel_case)
42
+
43
+ transformation.call 'i_am_a_camel'
44
+ # => "IAmACamel"
45
+
46
+ transformation = t(:map_array, (
47
+ t(:symbolize_keys).>> t(:rename_keys, user_name: :user)
48
+ )).>> t(:wrap, :address, [:city, :street, :zipcode])
49
+
50
+ transformation.call(
51
+ [
52
+ { 'user_name' => 'Jane',
53
+ 'city' => 'NYC',
54
+ 'street' => 'Street 1',
55
+ 'zipcode' => '123' }
56
+ ]
57
+ )
58
+ # => [{:user=>"Jane", :address=>{:city=>"NYC", :street=>"Street 1", :zipcode=>"123"}}]
59
+
60
+ # define your own composable transformation easily
61
+ transformation = t(-> v { JSON.dump(v) })
62
+
63
+ transformation.call(name: 'Jane')
64
+ # => "{\"name\":\"Jane\"}"
65
+
66
+ # ...or add it to registered functions via singleton method of the registry
67
+ module Functions
68
+ # ...
69
+
70
+ def self.load_json(v)
71
+ JSON.load(v)
72
+ end
73
+ end
74
+
75
+ # ...or add it to registered functions via .register method
76
+ Functions.register(:load_json) { |v| JSON.load(v) }
77
+
78
+ transformation = t(:load_json) >> t(:map_array, t(:symbolize_keys))
79
+
80
+ transformation.call('[{"name":"Jane"}]')
81
+ # => [{ :name => "Jane" }]
82
+ ```
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'dry/transformer/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'dry-transformer'
9
+ spec.version = Dry::Transformer::VERSION.dup
10
+ spec.authors = ['Piotr Solnica']
11
+ spec.email = ['piotr.solnica@gmail.com']
12
+ spec.summary = 'Data transformation toolkit'
13
+ spec.description = spec.summary
14
+ spec.homepage = 'https://dry-rb.org/gems/dry-transformer/'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0")
18
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ['lib']
21
+ spec.required_ruby_version = '>= 2.3.0'
22
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer'
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/version'
4
+ require 'dry/transformer/constants'
5
+ require 'dry/transformer/function'
6
+ require 'dry/transformer/error'
7
+ require 'dry/transformer/store'
8
+ require 'dry/transformer/registry'
9
+
10
+ require 'dry/transformer/array'
11
+ require 'dry/transformer/hash'
12
+
13
+ require 'dry/transformer/pipe'
14
+
15
+ module Dry
16
+ module Transformer
17
+ # @api public
18
+ # @see Pipe.[]
19
+ def self.[](registry)
20
+ Pipe[registry]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer'
4
+
5
+ require 'dry/transformer/class'
6
+ require 'dry/transformer/coercions'
7
+ require 'dry/transformer/conditional'
8
+ require 'dry/transformer/array'
9
+ require 'dry/transformer/hash'
10
+ require 'dry/transformer/proc'
11
+ require 'dry/transformer/recursion'
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/coercions'
4
+ require 'dry/transformer/hash'
5
+ require 'dry/transformer/array/combine'
6
+
7
+ module Dry
8
+ module Transformer
9
+ # Transformation functions for Array objects
10
+ #
11
+ # @example
12
+ # require 'dry/transformer/array'
13
+ #
14
+ # include Dry::Transformer::Helper
15
+ #
16
+ # fn = t(:map_array, t(:symbolize_keys)) >> t(:wrap, :address, [:city, :zipcode])
17
+ #
18
+ # fn.call(
19
+ # [
20
+ # { 'city' => 'Boston', 'zipcode' => '123' },
21
+ # { 'city' => 'NYC', 'zipcode' => '312' }
22
+ # ]
23
+ # )
24
+ # # => [{:address=>{:city=>"Boston", :zipcode=>"123"}}, {:address=>{:city=>"NYC", :zipcode=>"312"}}]
25
+ #
26
+ # @api public
27
+ module ArrayTransformations
28
+ extend Registry
29
+
30
+ # Map array values using transformation function
31
+ #
32
+ # @example
33
+ #
34
+ # fn = Dry::Transformer(:map_array, -> v { v.upcase })
35
+ #
36
+ # fn.call ['foo', 'bar'] # => ["FOO", "BAR"]
37
+ #
38
+ # @param [Array] array The input array
39
+ # @param [Proc] fn The transformation function
40
+ #
41
+ # @return [Array]
42
+ #
43
+ # @api public
44
+ def self.map_array(array, fn)
45
+ Array(array).map { |value| fn[value] }
46
+ end
47
+
48
+ # Wrap array values using HashTransformations.nest function
49
+ #
50
+ # @example
51
+ # fn = Dry::Transformer(:wrap, :address, [:city, :zipcode])
52
+ #
53
+ # fn.call [{ city: 'NYC', zipcode: '123' }]
54
+ # # => [{ address: { city: 'NYC', zipcode: '123' } }]
55
+ #
56
+ # @param [Array] array The input array
57
+ # @param [Object] key The nesting root key
58
+ # @param [Object] keys The nesting value keys
59
+ #
60
+ # @return [Array]
61
+ #
62
+ # @api public
63
+ def self.wrap(array, key, keys)
64
+ nest = HashTransformations[:nest, key, keys]
65
+ array.map { |element| nest.call(element) }
66
+ end
67
+
68
+ # Group array values using provided root key and value keys
69
+ #
70
+ # @example
71
+ # fn = Dry::Transformer(:group, :tags, [:tag])
72
+ #
73
+ # fn.call [
74
+ # { task: 'Group it', tag: 'task' },
75
+ # { task: 'Group it', tag: 'important' }
76
+ # ]
77
+ # # => [{ task: 'Group it', tags: [{ tag: 'task' }, { tag: 'important' }]]
78
+ #
79
+ # @param [Array] array The input array
80
+ # @param [Object] key The nesting root key
81
+ # @param [Object] keys The nesting value keys
82
+ #
83
+ # @return [Array]
84
+ #
85
+ # @api public
86
+ def self.group(array, key, keys)
87
+ grouped = Hash.new { |h, k| h[k] = [] }
88
+ array.each do |hash|
89
+ hash = Hash[hash]
90
+
91
+ old_group = Coercions.to_tuples(hash.delete(key))
92
+ new_group = keys.inject({}) { |a, e| a.merge(e => hash.delete(e)) }
93
+
94
+ grouped[hash] << old_group.map { |item| item.merge(new_group) }
95
+ end
96
+ grouped.map do |root, children|
97
+ root.merge(key => children.flatten)
98
+ end
99
+ end
100
+
101
+ # Ungroup array values using provided root key and value keys
102
+ #
103
+ # @example
104
+ # fn = Dry::Transformer(:ungroup, :tags, [:tag])
105
+ #
106
+ # fn.call [
107
+ # { task: 'Group it', tags: [{ tag: 'task' }, { tag: 'important' }] }
108
+ # ]
109
+ # # => [
110
+ # { task: 'Group it', tag: 'task' },
111
+ # { task: 'Group it', tag: 'important' }
112
+ # ]
113
+ #
114
+ # @param [Array] array The input array
115
+ # @param [Object] key The nesting root key
116
+ # @param [Object] keys The nesting value keys
117
+ #
118
+ # @return [Array]
119
+ #
120
+ # @api public
121
+ def self.ungroup(array, key, keys)
122
+ array.flat_map { |item| HashTransformations.split(item, key, keys) }
123
+ end
124
+
125
+ def self.combine(array, mappings)
126
+ Combine.combine(array, mappings)
127
+ end
128
+
129
+ # Converts the array of hashes to array of values, extracted by given key
130
+ #
131
+ # @example
132
+ # fn = t(:extract_key, :name)
133
+ # fn.call [
134
+ # { name: 'Alice', role: 'sender' },
135
+ # { name: 'Bob', role: 'receiver' },
136
+ # { role: 'listener' }
137
+ # ]
138
+ # # => ['Alice', 'Bob', nil]
139
+ #
140
+ # @param [Array<Hash>] array The input array of hashes
141
+ # @param [Object] key The key to extract values by
142
+ #
143
+ # @return [Array]
144
+ #
145
+ # @api public
146
+ def self.extract_key(array, key)
147
+ map_array(array, ->(v) { v[key] })
148
+ end
149
+
150
+ # Wraps every value of the array to tuple with given key
151
+ #
152
+ # The transformation partially inverses the `extract_key`.
153
+ #
154
+ # @example
155
+ # fn = t(:insert_key, 'name')
156
+ # fn.call ['Alice', 'Bob', nil]
157
+ # # => [{ 'name' => 'Alice' }, { 'name' => 'Bob' }, { 'name' => nil }]
158
+ #
159
+ # @param [Array<Hash>] array The input array of hashes
160
+ # @param [Object] key The key to extract values by
161
+ #
162
+ # @return [Array]
163
+ #
164
+ # @api public
165
+ def self.insert_key(array, key)
166
+ map_array(array, ->(v) { { key => v } })
167
+ end
168
+
169
+ # Adds missing keys with nil value to all tuples in array
170
+ #
171
+ # @param [Array] keys
172
+ #
173
+ # @return [Array]
174
+ #
175
+ # @api public
176
+ #
177
+ def self.add_keys(array, keys)
178
+ base = keys.inject({}) { |a, e| a.merge(e => nil) }
179
+ map_array(array, ->(v) { base.merge(v) })
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ module ArrayTransformations
6
+ class Combine
7
+ EMPTY_ARRAY = [].freeze
8
+
9
+ class << self
10
+ def combine(array, mappings)
11
+ root, nodes = array
12
+ return EMPTY_ARRAY if root.nil?
13
+ return root if nodes.nil?
14
+
15
+ groups = group_nodes(nodes, mappings)
16
+
17
+ root.map do |element|
18
+ element.dup.tap { |copy| add_groups_to_element(copy, groups, mappings) }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def add_groups_to_element(element, groups, mappings)
25
+ groups.each_with_index do |candidates, index|
26
+ mapping = mappings[index]
27
+ resource_key = mapping[0]
28
+ element[resource_key] = element_candidates(element, candidates, mapping[1].keys)
29
+ end
30
+ end
31
+
32
+ def element_candidates(element, candidates, keys)
33
+ candidates[element_candidates_key(element, keys)] || EMPTY_ARRAY
34
+ end
35
+
36
+ def group_nodes(nodes, mappings)
37
+ nodes.each_with_index.map do |candidates, index|
38
+ mapping = mappings[index]
39
+ group_candidates(candidates, mapping)
40
+ end
41
+ end
42
+
43
+ def group_candidates(candidates, mapping)
44
+ nested_mapping = mapping[2]
45
+ candidates = combine(candidates, nested_mapping) unless nested_mapping.nil?
46
+ group_candidates_by_keys(candidates, mapping[1].values)
47
+ end
48
+
49
+ def group_candidates_by_keys(candidates, keys)
50
+ return candidates.group_by { |a| a.values_at(*keys) } if keys.size > 1
51
+
52
+ key = keys.first
53
+ candidates.group_by { |a| a[key] }
54
+ end
55
+
56
+ def element_candidates_key(element, keys)
57
+ return element.values_at(*keys) if keys.size > 1
58
+
59
+ element[keys.first]
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end