checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '07286bcabed176ae3f17868f5e838decc2ba0c192828b6e69da92ec18f8713e0'
4
+ data.tar.gz: 3c5b577d2fc463182fd9fb28a333b594622c4afa61b276183f3247ce757c55e8
5
+ SHA512:
6
+ metadata.gz: e100d0e8782d778c4abf0c6f805bfbcafd1d86ce5876a898706114fa658ed3c3045345470adc6bd44a750b995a92d0033d95fadbe0e565bca5fccd52d7b4a993
7
+ data.tar.gz: 41ea28575f7d9691f8e36e14920b4a65718738086a96ebc5b73cee2f3108d8884a99f6da0b933c17b1cf270a314a93fbb39c4b30eb4c51cc0736d690b22be662
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # 2020-01-14
2
+
3
+ ## Fixed
4
+
5
+ Fixed Dry::Transformer::HashTransformations.unwrap when hash contains root key (@AMHOL)
6
+
7
+ [Compare v0.1.0...v0.1.1](https://github.com/dry-rb/dry-transaction/compare/v0.1.0...v0.1.1)
8
+
9
+ # v0.1.0 2019-12-28
10
+
11
+ Initial port of the [transproc](https://github.com/solnic/transproc) gem.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-2020 dry-rb team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ [gem]: https://rubygems.org/gems/dry-transformer
2
+ [actions]: https://github.com/dry-rb/dry-transformer/actions
3
+ [codacy]: https://www.codacy.com/gh/dry-rb/dry-transformer
4
+ [chat]: https://dry-rb.zulipchat.com
5
+ [inchpages]: http://inch-ci.org/github/dry-rb/dry-transformer
6
+
7
+ # dry-transformer [![Join the chat at https://dry-rb.zulipchat.com](https://img.shields.io/badge/dry--rb-join%20chat-%23346b7a.svg)][chat]
8
+
9
+ [![Gem Version](https://badge.fury.io/rb/dry-transformer.svg)][gem]
10
+ [![CI Status](https://github.com/dry-rb/dry-transformer/workflows/ci/badge.svg)][actions]
11
+ [![Codacy Badge](https://api.codacy.com/project/badge/Grade/22edf59617be4aef97cfbe4e1c99f1ce)][codacy]
12
+ [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/22edf59617be4aef97cfbe4e1c99f1ce)][codacy]
13
+ [![Inline docs](http://inch-ci.org/github/dry-rb/dry-transformer.svg?branch=master)][inchpages]
14
+
15
+ ## Links
16
+
17
+ * [User documentation](http://dry-rb.org/gems/dry-transformer)
18
+ * [API documentation](http://rubydoc.info/gems/dry-transformer)
19
+
20
+ ## Supported Ruby versions
21
+
22
+ This library officially supports the following Ruby versions:
23
+
24
+ * MRI >= `2.4`
25
+ * jruby >= `9.2`
26
+
27
+ ## License
28
+
29
+ See `LICENSE` file.
data/dry-transformer.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ # this file is managed by dry-rb/devtools project
3
+
4
+ lib = File.expand_path('lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+ require 'dry/transformer/version'
7
+
8
+ Gem::Specification.new do |spec|
9
+ spec.name = 'dry-transformer'
10
+ spec.authors = ["Piotr Solnica"]
11
+ spec.email = ["piotr.solnica@gmail.com"]
12
+ spec.license = 'MIT'
13
+ spec.version = Dry::Transformer::VERSION.dup
14
+
15
+ spec.summary = "Data transformation toolkit"
16
+ spec.description = spec.summary
17
+ spec.homepage = 'https://dry-rb.org/gems/dry-transformer'
18
+ spec.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'dry-transformer.gemspec', 'lib/**/*']
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
22
+ spec.metadata['changelog_uri'] = 'https://github.com/dry-rb/dry-transformer/blob/master/CHANGELOG.md'
23
+ spec.metadata['source_code_uri'] = 'https://github.com/dry-rb/dry-transformer'
24
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/dry-rb/dry-transformer/issues'
25
+
26
+ spec.required_ruby_version = '>= 2.4.0'
27
+
28
+ # to update dependencies edit project.yml
29
+ end
data/lib/dry-transformer.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer'
data/lib/dry/transformer.rb ADDED
@@ -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
data/lib/dry/transformer/all.rb ADDED
@@ -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'
data/lib/dry/transformer/array.rb ADDED
@@ -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
data/lib/dry/transformer/array/combine.rb ADDED
@@ -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
data/lib/dry/transformer/class.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Transformation functions for Classes
6
+ #
7
+ # @example
8
+ # require 'dry/transformer/class'
9
+ #
10
+ # include Dry::Transformer::Helper
11
+ #
12
+ # fn = t(:constructor_inject, Struct)
13
+ #
14
+ # fn['User', :name, :age]
15
+ # # => Struct::User
16
+ #
17
+ # @api public
18
+ module ClassTransformations
19
+ extend Registry
20
+
21
+ # Inject given arguments into the constructor of the class
22
+ #
23
+ # @example
24
+ # Transproct(:constructor_inject, Struct)['User', :name, :age]
25
+ # # => Struct::User
26
+ #
27
+ # @param [*Mixed] A list of arguments to inject
28
+ #
29
+ # @return [Object] An instance of the given klass
30
+ #
31
+ # @api public
32
+ def self.constructor_inject(*args, klass)
33
+ klass.new(*args)
34
+ end
35
+
36
+ # Set instance variables from the hash argument (key/value pairs) on the object
37
+ #
38
+ # @example
39
+ # Dry::Transformer(:set_ivars, Object)[name: 'Jane', age: 25]
40
+ # # => #<Object:0x007f411d06a210 @name="Jane", @age=25>
41
+ #
42
+ # @param [Object]
43
+ #
44
+ # @return [Object]
45
+ #
46
+ # @api public
47
+ def self.set_ivars(ivar_hash, klass)
48
+ object = klass.allocate
49
+ ivar_hash.each do |ivar_name, ivar_value|
50
+ object.instance_variable_set("@#{ivar_name}", ivar_value)
51
+ end
52
+ object
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/dry/transformer/coercions.rb ADDED
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+ require 'bigdecimal'
6
+ require 'bigdecimal/util'
7
+
8
+ module Dry
9
+ module Transformer
10
+ # Coercion functions for common types
11
+ #
12
+ # @api public
13
+ module Coercions
14
+ extend Registry
15
+
16
+ TRUE_VALUES = [true, 1, '1', 'on', 't', 'true', 'y', 'yes'].freeze
17
+ FALSE_VALUES = [false, 0, '0', 'off', 'f', 'false', 'n', 'no', nil].freeze
18
+
19
+ BOOLEAN_MAP = Hash[
20
+ TRUE_VALUES.product([true]) + FALSE_VALUES.product([false])
21
+ ].freeze
22
+
23
+ # Does nothing and returns a value
24
+ #
25
+ # @example
26
+ # fn = Coercions[:identity]
27
+ # fn[:foo] # => :foo
28
+ #
29
+ # @param [Object] value
30
+ #
31
+ # @return [Object]
32
+ #
33
+ # @api public
34
+ def self.identity(value = nil)
35
+ value
36
+ end
37
+
38
+ # Coerce value into a string
39
+ #
40
+ # @example
41
+ # Dry::Transformer(:to_string)[1]
42
+ # # => "1"
43
+ #
44
+ # @param [Object] value The input value
45
+ #
46
+ # @return [String]
47
+ #
48
+ # @api public
49
+ def self.to_string(value)
50
+ value.to_s
51
+ end
52
+
53
+ # Coerce value into a symbol
54
+ #
55
+ # @example
56
+ # Dry::Transformer(:to_symbol)['foo']
57
+ # # => :foo
58
+ #
59
+ # @param [#to_s] value The input value
60
+ #
61
+ # @return [Symbol]
62
+ #
63
+ # @api public
64
+ def self.to_symbol(value)
65
+ value.to_s.to_sym
66
+ end
67
+
68
+ # Coerce value into a integer
69
+ #
70
+ # @example
71
+ # Dry::Transformer(:to_integer)['1']
72
+ # # => 1
73
+ #
74
+ # @param [Object] value The input value
75
+ #
76
+ # @return [Integer]
77
+ #
78
+ # @api public
79
+ def self.to_integer(value)
80
+ value.to_i
81
+ end
82
+
83
+ # Coerce value into a float
84
+ #
85
+ # @example
86
+ # Dry::Transformer(:to_float)['1.2']
87
+ # # => 1.2
88
+ #
89
+ # @param [Object] value The input value
90
+ #
91
+ # @return [Float]
92
+ #
93
+ # @api public
94
+ def self.to_float(value)
95
+ value.to_f
96
+ end
97
+
98
+ # Coerce value into a decimal
99
+ #
100
+ # @example
101
+ # Dry::Transformer(:to_decimal)[1.2]
102
+ # # => #<BigDecimal:7fca32acea50,'0.12E1',18(36)>
103
+ #
104
+ # @param [Object] value The input value
105
+ #
106
+ # @return [Decimal]
107
+ #
108
+ # @api public
109
+ def self.to_decimal(value)
110
+ value.to_d
111
+ end
112
+
113
+ # Coerce value into a boolean
114
+ #
115
+ # @example
116
+ # Dry::Transformer(:to_boolean)['true']
117
+ # # => true
118
+ # Dry::Transformer(:to_boolean)['f']
119
+ # # => false
120
+ #
121
+ # @param [Object] value The input value
122
+ #
123
+ # @return [TrueClass,FalseClass]
124
+ #
125
+ # @api public
126
+ def self.to_boolean(value)
127
+ BOOLEAN_MAP.fetch(value)
128
+ end
129
+
130
+ # Coerce value into a date
131
+ #
132
+ # @example
133
+ # Dry::Transformer(:to_date)['2015-04-14']
134
+ # # => #<Date: 2015-04-14 ((2457127j,0s,0n),+0s,2299161j)>
135
+ #
136
+ # @param [Object] value The input value
137
+ #
138
+ # @return [Date]
139
+ #
140
+ # @api public
141
+ def self.to_date(value)
142
+ Date.parse(value)
143
+ end
144
+
145
+ # Coerce value into a time
146
+ #
147
+ # @example
148
+ # Dry::Transformer(:to_time)['2015-04-14 12:01:45']
149
+ # # => 2015-04-14 12:01:45 +0200
150
+ #
151
+ # @param [Object] value The input value
152
+ #
153
+ # @return [Time]
154
+ #
155
+ # @api public
156
+ def self.to_time(value)
157
+ Time.parse(value)
158
+ end
159
+
160
+ # Coerce value into a datetime
161
+ #
162
+ # @example
163
+ # Dry::Transformer(:to_datetime)['2015-04-14 12:01:45']
164
+ # # => #<DateTime: 2015-04-14T12:01:45+00:00 ((2457127j,43305s,0n),+0s,2299161j)>
165
+ #
166
+ # @param [Object] value The input value
167
+ #
168
+ # @return [DateTime]
169
+ #
170
+ # @api public
171
+ def self.to_datetime(value)
172
+ DateTime.parse(value)
173
+ end
174
+
175
+ # Coerce value into an array containing tuples only
176
+ #
177
+ # If the source is not an array, or doesn't contain a tuple, returns
178
+ # an array with one empty tuple
179
+ #
180
+ # @example
181
+ # Dry::Transformer(:to_tuples)[:foo] # => [{}]
182
+ # Dry::Transformer(:to_tuples)[[]] # => [{}]
183
+ # Dry::Transformer(:to_tuples)[[{ foo: :FOO, :bar }]] # => [{ foo: :FOO }]
184
+ #
185
+ # @param [Object] value
186
+ #
187
+ # @return [Array<Hash>]
188
+ #
189
+ def self.to_tuples(value)
190
+ array = value.is_a?(Array) ? Array[*value] : [{}]
191
+ array.select! { |item| item.is_a?(Hash) }
192
+ array.any? ? array : [{}]
193
+ end
194
+ end
195
+ end
196
+ end
data/lib/dry/transformer/compiler.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # @api private
6
+ class Compiler
7
+ InvalidFunctionNameError = Class.new(StandardError)
8
+
9
+ attr_reader :registry, :transformer
10
+
11
+ def initialize(registry, transformer = nil)
12
+ @registry = registry
13
+ @transformer = transformer
14
+ end
15
+
16
+ def call(ast)
17
+ ast.map(&method(:visit)).reduce(:>>)
18
+ end
19
+
20
+ def visit(node)
21
+ id, *rest = node
22
+ public_send(:"visit_#{id}", *rest)
23
+ end
24
+
25
+ def visit_fn(node)
26
+ name, rest = node
27
+ args = rest.map { |arg| visit(arg) }
28
+
29
+ if registry.contain?(name)
30
+ registry[name, *args]
31
+ elsif transformer.respond_to?(name)
32
+ Function.new(transformer.method(name), name: name, args: args)
33
+ else
34
+ raise InvalidFunctionNameError, "function name +#{name}+ is not valid"
35
+ end
36
+ end
37
+
38
+ def visit_arg(arg)
39
+ arg
40
+ end
41
+
42
+ def visit_t(node)
43
+ call(node)
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/dry/transformer/composite.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Composition of two functions
6
+ #
7
+ # @api private
8
+ class Composite
9
+ # @return [Proc]
10
+ #
11
+ # @api private
12
+ attr_reader :left
13
+
14
+ # @return [Proc]
15
+ #
16
+ # @api private
17
+ attr_reader :right
18
+
19
+ # @api private
20
+ def initialize(left, right)
21
+ @left = left
22
+ @right = right
23
+ end
24
+
25
+ # Call right side with the result from the left side
26
+ #
27
+ # @param [Object] value The input value
28
+ #
29
+ # @return [Object]
30
+ #
31
+ # @api public
32
+ def call(value)
33
+ right.call(left.call(value))
34
+ end
35
+ alias_method :[], :call
36
+
37
+ # @see Function#compose
38
+ #
39
+ # @api public
40
+ def compose(other)
41
+ self.class.new(self, other)
42
+ end
43
+ alias_method :+, :compose
44
+ alias_method :>>, :compose
45
+
46
+ # @see Function#to_ast
47
+ #
48
+ # @api public
49
+ def to_ast
50
+ left.to_ast << right.to_ast
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/dry/transformer/conditional.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Conditional transformation functions
6
+ #
7
+ # @example
8
+ # require 'dry/transformer/conditional'
9
+ #
10
+ # include Dry::Transformer::Helper
11
+ #
12
+ # fn = t(:guard, -> s { s.is_a?(::String) }, -> s { s.to_sym })
13
+ #
14
+ # [fn[2], fn['Jane']]
15
+ # # => [2, :Jane]
16
+ #
17
+ # @api public
18
+ module Conditional
19
+ extend Registry
20
+
21
+ # Negates the result of transformation
22
+ #
23
+ # @example
24
+ # fn = Conditional[:not, -> value { value.is_a? ::String }]
25
+ # fn[:foo] # => true
26
+ # fn["foo"] # => false
27
+ #
28
+ # @param [Object] value
29
+ # @param [Proc] fn
30
+ #
31
+ # @return [Boolean]
32
+ #
33
+ # @api public
34
+ def self.not(value, fn)
35
+ !fn[value]
36
+ end
37
+
38
+ # Apply the transformation function to subject if the predicate returns true, or return un-modified
39
+ #
40
+ # @example
41
+ # [2, 'Jane'].map do |subject|
42
+ # Dry::Transformer(:guard, -> s { s.is_a?(::String) }, -> s { s.to_sym })[subject]
43
+ # end
44
+ # # => [2, :Jane]
45
+ #
46
+ # @param [Mixed]
47
+ #
48
+ # @return [Mixed]
49
+ #
50
+ # @api public
51
+ def self.guard(value, predicate, fn)
52
+ predicate[value] ? fn[value] : value
53
+ end
54
+
55
+ # Calls a function when type-check passes
56
+ #
57
+ # @example
58
+ # fn = Dry::Transformer(:is, Array, -> arr { arr.map(&:upcase) })
59
+ # fn.call(['a', 'b', 'c']) # => ['A', 'B', 'C']
60
+ #
61
+ # fn = Dry::Transformer(:is, Array, -> arr { arr.map(&:upcase) })
62
+ # fn.call('foo') # => "foo"
63
+ #
64
+ # @param [Object]
65
+ # @param [Class]
66
+ # @param [Proc]
67
+ #
68
+ # @return [Object]
69
+ #
70
+ # @api public
71
+ def self.is(value, type, fn)
72
+ guard(value, -> v { v.is_a?(type) }, fn)
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/dry/transformer/constants.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ Undefined = Object.new.freeze
6
+ end
7
+ end
data/lib/dry/transformer/error.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ Error = Class.new(StandardError)
6
+ FunctionAlreadyRegisteredError = Class.new(Error)
7
+
8
+ class FunctionNotFoundError < Error
9
+ def initialize(function, source = nil)
10
+ return super "No registered function #{source}[:#{function}]" if source
11
+
12
+ super "No globally registered function for #{function}"
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/dry/transformer/function.rb ADDED
@@ -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
data/lib/dry/transformer/hash.rb ADDED
@@ -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
data/lib/dry/transformer/pipe.rb ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/pipe/class_interface'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Pipe class for defining transprocs with a class DSL.
8
+ #
9
+ # @example
10
+ # require 'anima'
11
+ # require 'dry/transformer/all'
12
+ #
13
+ # class User
14
+ # include Anima.new(:name, :address)
15
+ # end
16
+ #
17
+ # class Address
18
+ # include Anima.new(:city, :street, :zipcode)
19
+ # end
20
+ #
21
+ # class UsersMapper < Dry::Transformer::Pipe
22
+ # map_array do
23
+ # symbolize_keys
24
+ # rename_keys user_name: :name
25
+ # nest :address, %i(city street zipcode)
26
+ # map_value :address do
27
+ # constructor_inject Address
28
+ # end
29
+ # constructor_inject User
30
+ # end
31
+ # end
32
+ #
33
+ # UsersMapper.new.call(
34
+ # [
35
+ # { 'user_name' => 'Jane',
36
+ # 'city' => 'NYC',
37
+ # 'street' => 'Street 1',
38
+ # 'zipcode' => '123'
39
+ # }
40
+ # ]
41
+ # )
42
+ # # => [
43
+ # #<User
44
+ # name="Jane"
45
+ # address=#<Address city="NYC" street="Street 1" zipcode="123">>
46
+ # ]
47
+ #
48
+ # @api public
49
+ class Pipe
50
+ extend ClassInterface
51
+
52
+ attr_reader :transproc
53
+
54
+ # Execute the transformation pipeline with the given input.
55
+ #
56
+ # @example
57
+ #
58
+ # class SymbolizeKeys < Dry::Transformer
59
+ # symbolize_keys
60
+ # end
61
+ #
62
+ # SymbolizeKeys.new.call('name' => 'Jane')
63
+ # # => {:name=>"Jane"}
64
+ #
65
+ # @param [mixed] input The input to pass to the pipeline
66
+ #
67
+ # @return [mixed] output The output returned from the pipeline
68
+ #
69
+ # @api public
70
+ def call(input)
71
+ transproc.call(input)
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/dry/transformer/pipe/class_interface.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/pipe/dsl'
4
+
5
+ module Dry
6
+ module Transformer
7
+ class Pipe
8
+ # @api public
9
+ module ClassInterface
10
+ # @api private
11
+ attr_reader :dsl
12
+
13
+ # Return a base Dry::Transformer class with the
14
+ # container configured to the passed argument.
15
+ #
16
+ # @example
17
+ #
18
+ # class MyTransformer < Dry::Transformer[Transproc]
19
+ # end
20
+ #
21
+ # @param [Transproc::Registry] container
22
+ # The container to resolve transprocs from
23
+ #
24
+ # @return [subclass of Dry::Transformer]
25
+ #
26
+ # @api public
27
+ def [](container)
28
+ klass = Class.new(self)
29
+ klass.container(container)
30
+ klass
31
+ end
32
+
33
+ # @api private
34
+ def inherited(subclass)
35
+ super
36
+
37
+ subclass.container(@container) if defined?(@container)
38
+
39
+ subclass.instance_variable_set('@dsl', dsl.dup) if dsl
40
+ end
41
+
42
+ # Get or set the container to resolve transprocs from.
43
+ #
44
+ # @example
45
+ #
46
+ # # Setter
47
+ # Dry::Transformer.container(Transproc)
48
+ # # => Transproc
49
+ #
50
+ # # Getter
51
+ # Dry::Transformer.container
52
+ # # => Transproc
53
+ #
54
+ # @param [Transproc::Registry] container
55
+ # The container to resolve transprocs from
56
+ #
57
+ # @return [Transproc::Registry]
58
+ #
59
+ # @api private
60
+ def container(container = Undefined)
61
+ if container.equal?(Undefined)
62
+ @container ||= Module.new.extend(Dry::Transformer::Registry)
63
+ else
64
+ @container = container
65
+ end
66
+ end
67
+
68
+ # @api public
69
+ def import(*args)
70
+ container.import(*args)
71
+ end
72
+
73
+ # @api public
74
+ def define!(&block)
75
+ @dsl ||= DSL.new(container)
76
+ @dsl.instance_eval(&block)
77
+ self
78
+ end
79
+
80
+ # @api public
81
+ def new(*)
82
+ super.tap do |transformer|
83
+ transformer.instance_variable_set('@transproc', dsl.(transformer)) if dsl
84
+ end
85
+ end
86
+ ruby2_keywords(:new) if respond_to?(:ruby2_keywords, true)
87
+
88
+ # Get a transformation from the container,
89
+ # without adding it to the transformation pipeline
90
+ #
91
+ # @example
92
+ #
93
+ # class Stringify < Dry::Transformer
94
+ # map_values t(:to_string)
95
+ # end
96
+ #
97
+ # Stringify.new.call(a: 1, b: 2)
98
+ # # => {a: '1', b: '2'}
99
+ #
100
+ # @param [Proc, Symbol] fn
101
+ # A proc, a name of the module's own function, or a name of imported
102
+ # procedure from another module
103
+ # @param [Object, Array] args
104
+ # Args to be carried by the transproc
105
+ #
106
+ # @return [Transproc::Function]
107
+ #
108
+ # @api public
109
+ def t(fn, *args)
110
+ container[fn, *args]
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/dry/transformer/pipe/dsl.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/compiler'
4
+
5
+ module Dry
6
+ module Transformer
7
+ class Pipe
8
+ # @api public
9
+ class DSL
10
+ # @api private
11
+ attr_reader :container
12
+
13
+ # @api private
14
+ attr_reader :ast
15
+
16
+ # @api private
17
+ def initialize(container, ast: [], &block)
18
+ @container = container
19
+ @ast = ast
20
+ instance_eval(&block) if block
21
+ end
22
+
23
+ # @api public
24
+ def t(name, *args)
25
+ container[name, *args]
26
+ end
27
+
28
+ # @api private
29
+ def dup
30
+ self.class.new(container, ast: ast.dup)
31
+ end
32
+
33
+ # @api private
34
+ def call(transformer)
35
+ Compiler.new(container, transformer).(ast)
36
+ end
37
+
38
+ private
39
+
40
+ # @api private
41
+ def node(&block)
42
+ [:t, self.class.new(container, &block).ast]
43
+ end
44
+
45
+ # @api private
46
+ def respond_to_missing?(method, _include_private = false)
47
+ super || container.contain?(method)
48
+ end
49
+
50
+ # @api private
51
+ def method_missing(meth, *args, &block)
52
+ arg_nodes = *args.map { |a| [:arg, a] }
53
+ ast << [:fn, (block ? [meth, [*arg_nodes, node(&block)]] : [meth, arg_nodes])]
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
data/lib/dry/transformer/proc.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Transformation functions for Procs
6
+ #
7
+ # @example
8
+ # require 'ostruct'
9
+ # require 'dry/transformer/proc'
10
+ #
11
+ # include Dry::Transformer::Helper
12
+ #
13
+ # fn = t(
14
+ # :map_value,
15
+ # 'foo_bar',
16
+ # t(:bind, OpenStruct.new(prefix: 'foo'), -> s { [prefix, s].join('_') })
17
+ # )
18
+ #
19
+ # fn["foo_bar" => "bar"]
20
+ # # => {"foo_bar" => "foo_bar"}
21
+ #
22
+ # @api public
23
+ module ProcTransformations
24
+ extend Registry
25
+
26
+ # Change the binding for the given function
27
+ #
28
+ # @example
29
+ # Dry::Transformer(
30
+ # :bind,
31
+ # OpenStruct.new(prefix: 'foo'),
32
+ # -> s { [prefix, s].join('_') }
33
+ # )['bar']
34
+ # # => "foo_bar"
35
+ #
36
+ # @param [Proc]
37
+ #
38
+ # @return [Proc]
39
+ #
40
+ # @api public
41
+ def self.bind(value, binding, fn)
42
+ binding.instance_exec(value, &fn)
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/dry/transformer/recursion.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/conditional'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Recursive transformation functions
8
+ #
9
+ # @example
10
+ # require 'dry/transformer/recursion'
11
+ #
12
+ # include Dry::Transformer::Helper
13
+ #
14
+ # fn = t(:hash_recursion, t(:symbolize_keys))
15
+ #
16
+ # fn["name" => "Jane", "address" => { "street" => "Street 1" }]
17
+ # # => {:name=>"Jane", :address=>{:street=>"Street 1"}}
18
+ #
19
+ # @api public
20
+ module Recursion
21
+ extend Registry
22
+
23
+ IF_ENUMERABLE = -> fn { Conditional[:is, Enumerable, fn] }
24
+
25
+ IF_ARRAY = -> fn { Conditional[:is, Array, fn] }
26
+
27
+ IF_HASH = -> fn { Conditional[:is, Hash, fn] }
28
+
29
+ # Recursively apply the provided transformation function to an enumerable
30
+ #
31
+ # @example
32
+ # Dry::Transformer(:recursion, Dry::Transformer(:is, ::Hash, Dry::Transformer(:symbolize_keys)))[
33
+ # {
34
+ # 'id' => 1,
35
+ # 'name' => 'Jane',
36
+ # 'tasks' => [
37
+ # { 'id' => 1, 'description' => 'Write some code' },
38
+ # { 'id' => 2, 'description' => 'Write some more code' }
39
+ # ]
40
+ # }
41
+ # ]
42
+ # => {
43
+ # :id=>1,
44
+ # :name=>"Jane",
45
+ # :tasks=>[
46
+ # {:id=>1, :description=>"Write some code"},
47
+ # {:id=>2, :description=>"Write some more code"}
48
+ # ]
49
+ # }
50
+ #
51
+ # @param [Enumerable]
52
+ #
53
+ # @return [Enumerable]
54
+ #
55
+ # @api public
56
+ def self.recursion(value, fn)
57
+ result = fn[value]
58
+ guarded = IF_ENUMERABLE[-> v { recursion(v, fn) }]
59
+
60
+ case result
61
+ when ::Hash
62
+ result.keys.each do |key|
63
+ result[key] = guarded[result.delete(key)]
64
+ end
65
+ when ::Array
66
+ result.map! do |item|
67
+ guarded[item]
68
+ end
69
+ end
70
+
71
+ result
72
+ end
73
+
74
+ # Recursively apply the provided transformation function to an array
75
+ #
76
+ # @example
77
+ # Dry::Transformer(:array_recursion, -> s { s.compact })[
78
+ # [['Joe', 'Jane', nil], ['Smith', 'Doe', nil]]
79
+ # ]
80
+ # # => [["Joe", "Jane"], ["Smith", "Doe"]]
81
+ #
82
+ # @param [Array]
83
+ #
84
+ # @return [Array]
85
+ #
86
+ # @api public
87
+ def self.array_recursion(value, fn)
88
+ result = fn[value]
89
+ guarded = IF_ARRAY[-> v { array_recursion(v, fn) }]
90
+
91
+ result.map! do |item|
92
+ guarded[item]
93
+ end
94
+ end
95
+
96
+ # Recursively apply the provided transformation function to a hash
97
+ #
98
+ # @example
99
+ # Dry::Transformer(:hash_recursion, Dry::Transformer(:symbolize_keys))[
100
+ # ["name" => "Jane", "address" => { "street" => "Street 1", "zipcode" => "123" }]
101
+ # ]
102
+ # # => {:name=>"Jane", :address=>{:street=>"Street 1", :zipcode=>"123"}}
103
+ #
104
+ # @param [Hash]
105
+ #
106
+ # @return [Hash]
107
+ #
108
+ # @api public
109
+ def self.hash_recursion(value, fn)
110
+ result = fn[value]
111
+ guarded = IF_HASH[-> v { hash_recursion(v, fn) }]
112
+
113
+ result.keys.each do |key|
114
+ result[key] = guarded[result.delete(key)]
115
+ end
116
+
117
+ result
118
+ end
119
+ end
120
+ end
121
+ end
data/lib/dry/transformer/registry.rb ADDED
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Container to define transproc functions in, and access them via `[]` method
6
+ # from the outside of the module
7
+ #
8
+ # @example
9
+ # module FooMethods
10
+ # extend Dry::Transformer::Registry
11
+ #
12
+ # def self.foo(name, prefix)
13
+ # [prefix, '_', name].join
14
+ # end
15
+ # end
16
+ #
17
+ # fn = FooMethods[:foo, 'baz']
18
+ # fn['qux'] # => 'qux_baz'
19
+ #
20
+ # module BarMethods
21
+ # extend FooMethods
22
+ #
23
+ # def self.bar(*args)
24
+ # foo(*args).upcase
25
+ # end
26
+ # end
27
+ #
28
+ # fn = BarMethods[:foo, 'baz']
29
+ # fn['qux'] # => 'qux_baz'
30
+ #
31
+ # fn = BarMethods[:bar, 'baz']
32
+ # fn['qux'] # => 'QUX_BAZ'
33
+ #
34
+ # @api public
35
+ module Registry
36
+ # Builds the transformation
37
+ #
38
+ # @param [Proc, Symbol] fn
39
+ # A proc, a name of the module's own function, or a name of imported
40
+ # procedure from another module
41
+ # @param [Object, Array] args
42
+ # Args to be carried by the transproc
43
+ #
44
+ # @return [Dry::Transformer::Function]
45
+ #
46
+ # @alias :t
47
+ #
48
+ def [](fn, *args)
49
+ fetched = fetch(fn)
50
+
51
+ return Function.new(fetched, args: args, name: fn) unless already_wrapped?(fetched)
52
+
53
+ args.empty? ? fetched : fetched.with(*args)
54
+ end
55
+ alias_method :t, :[]
56
+
57
+ # Returns wether the registry contains such transformation by its key
58
+ #
59
+ # @param [Symbol] key
60
+ #
61
+ # @return [Boolean]
62
+ #
63
+ def contain?(key)
64
+ respond_to?(key) || store.contain?(key)
65
+ end
66
+
67
+ # Register a new function
68
+ #
69
+ # @example
70
+ # store.register(:to_json, -> v { v.to_json })
71
+
72
+ # store.register(:to_json) { |v| v.to_json }
73
+ #
74
+ def register(name, fn = nil, &block)
75
+ if contain?(name)
76
+ raise FunctionAlreadyRegisteredError, "Function #{name} is already defined"
77
+ end
78
+
79
+ @store = store.register(name, fn, &block)
80
+ self
81
+ end
82
+
83
+ # Imports either a method (converted to a proc) from another module, or
84
+ # all methods from that module.
85
+ #
86
+ # If the external module is a registry, looks for its imports too.
87
+ #
88
+ # @overload import(source)
89
+ # Loads all methods from the source object
90
+ #
91
+ # @param [Object] source
92
+ #
93
+ # @overload import(*names, **options)
94
+ # Loads selected methods from the source object
95
+ #
96
+ # @param [Array<Symbol>] names
97
+ # @param [Hash] options
98
+ # @options options [Object] :from The source object
99
+ #
100
+ # @overload import(name, **options)
101
+ # Loads selected methods from the source object
102
+ #
103
+ # @param [Symbol] name
104
+ # @param [Hash] options
105
+ # @options options [Object] :from The source object
106
+ # @options options [Object] :as The new name for the transformation
107
+ #
108
+ # @return [itself] self
109
+ #
110
+ # @alias :import
111
+ #
112
+ def import(*args)
113
+ @store = store.import(*args)
114
+ self
115
+ end
116
+ alias_method :uses, :import
117
+
118
+ # The store of procedures imported from external modules
119
+ #
120
+ # @return [Dry::Transformer::Store]
121
+ #
122
+ def store
123
+ @store ||= Store.new
124
+ end
125
+
126
+ # Gets the procedure for creating a transproc
127
+ #
128
+ # @param [#call, Symbol] fn
129
+ # Either the procedure, or the name of the method of the current module,
130
+ # or the registered key of imported procedure in a store.
131
+ #
132
+ # @return [#call]
133
+ #
134
+ def fetch(fn)
135
+ return fn unless fn.instance_of? Symbol
136
+
137
+ respond_to?(fn) ? method(fn) : store.fetch(fn)
138
+ rescue StandardError
139
+ raise FunctionNotFoundError.new(fn, self)
140
+ end
141
+
142
+ private
143
+
144
+ # @api private
145
+ def already_wrapped?(func)
146
+ func.is_a?(Dry::Transformer::Function) || func.is_a?(Dry::Transformer::Composite)
147
+ end
148
+ end
149
+ end
150
+ end
data/lib/dry/transformer/store.rb ADDED
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Immutable collection of named procedures from external modules
6
+ #
7
+ # @api private
8
+ #
9
+ class Store
10
+ # @!attribute [r] methods
11
+ #
12
+ # @return [Hash] The associated list of imported procedures
13
+ #
14
+ attr_reader :methods
15
+
16
+ # @!scope class
17
+ # @!name new(methods = {})
18
+ # Creates an immutable store with a hash of procedures
19
+ #
20
+ # @param [Hash] methods
21
+ #
22
+ # @return [Dry::Transformer::Store]
23
+
24
+ # @private
25
+ def initialize(methods = {})
26
+ @methods = methods.dup.freeze
27
+ freeze
28
+ end
29
+
30
+ # Returns a procedure by its key in the collection
31
+ #
32
+ # @param [Symbol] key
33
+ #
34
+ # @return [Proc]
35
+ #
36
+ def fetch(key)
37
+ methods.fetch(key)
38
+ end
39
+
40
+ # Returns wether the collection contains such procedure by its key
41
+ #
42
+ # @param [Symbol] key
43
+ #
44
+ # @return [Boolean]
45
+ #
46
+ def contain?(key)
47
+ methods.key?(key)
48
+ end
49
+
50
+ # Register a new function
51
+ #
52
+ # @example
53
+ # store.register(:to_json, -> v { v.to_json })
54
+
55
+ # store.register(:to_json) { |v| v.to_json }
56
+ #
57
+ def register(name, fn = nil, &block)
58
+ self.class.new(methods.merge(name => fn || block))
59
+ end
60
+
61
+ # Imports proc(s) to the collection from another module
62
+ #
63
+ # @private
64
+ #
65
+ def import(*args)
66
+ first = args.first
67
+ return import_all(first) if first.instance_of?(Module)
68
+
69
+ opts = args.pop
70
+ source = opts.fetch(:from)
71
+ rename = opts.fetch(:as) { first.to_sym }
72
+
73
+ return import_methods(source, args) if args.count > 1
74
+
75
+ import_method(source, first, rename)
76
+ end
77
+
78
+ protected
79
+
80
+ # Creates new immutable collection from the current one,
81
+ # updated with either the module's singleton method,
82
+ # or the proc having been imported from another module.
83
+ #
84
+ # @param [Module] source
85
+ # @param [Symbol] name
86
+ # @param [Symbol] new_name
87
+ #
88
+ # @return [Dry::Transformer::Store]
89
+ #
90
+ def import_method(source, name, new_name = name)
91
+ from = name.to_sym
92
+ to = new_name.to_sym
93
+
94
+ fn = source.is_a?(Registry) ? source.fetch(from) : source.method(from)
95
+ self.class.new(methods.merge(to => fn))
96
+ end
97
+
98
+ # Creates new immutable collection from the current one,
99
+ # updated with either the module's singleton methods,
100
+ # or the procs having been imported from another module.
101
+ #
102
+ # @param [Module] source
103
+ # @param [Array<Symbol>] names
104
+ #
105
+ # @return [Dry::Transformer::Store]
106
+ #
107
+ def import_methods(source, names)
108
+ names.inject(self) { |a, e| a.import_method(source, e) }
109
+ end
110
+
111
+ # Creates new immutable collection from the current one,
112
+ # updated with all singleton methods and imported methods
113
+ # from the other module
114
+ #
115
+ # @param [Module] source The module to import procedures from
116
+ #
117
+ # @return [Dry::Transformer::Store]
118
+ #
119
+ def import_all(source)
120
+ names = source.public_methods - Registry.instance_methods - Module.methods
121
+ names -= [:initialize] # for compatibility with Rubinius
122
+ names += source.store.methods.keys if source.is_a? Registry
123
+
124
+ import_methods(source, names)
125
+ end
126
+ end
127
+ end
128
+ end
data/lib/dry/transformer/version.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ VERSION = '0.1.1'
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dry-transformer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Piotr Solnica
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-01-14 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Data transformation toolkit
14
+ email:
15
+ - piotr.solnica@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - dry-transformer.gemspec
24
+ - lib/dry-transformer.rb
25
+ - lib/dry/transformer.rb
26
+ - lib/dry/transformer/all.rb
27
+ - lib/dry/transformer/array.rb
28
+ - lib/dry/transformer/array/combine.rb
29
+ - lib/dry/transformer/class.rb
30
+ - lib/dry/transformer/coercions.rb
31
+ - lib/dry/transformer/compiler.rb
32
+ - lib/dry/transformer/composite.rb
33
+ - lib/dry/transformer/conditional.rb
34
+ - lib/dry/transformer/constants.rb
35
+ - lib/dry/transformer/error.rb
36
+ - lib/dry/transformer/function.rb
37
+ - lib/dry/transformer/hash.rb
38
+ - lib/dry/transformer/pipe.rb
39
+ - lib/dry/transformer/pipe/class_interface.rb
40
+ - lib/dry/transformer/pipe/dsl.rb
41
+ - lib/dry/transformer/proc.rb
42
+ - lib/dry/transformer/recursion.rb
43
+ - lib/dry/transformer/registry.rb
44
+ - lib/dry/transformer/store.rb
45
+ - lib/dry/transformer/version.rb
46
+ homepage: https://dry-rb.org/gems/dry-transformer
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ allowed_push_host: https://rubygems.org
51
+ changelog_uri: https://github.com/dry-rb/dry-transformer/blob/master/CHANGELOG.md
52
+ source_code_uri: https://github.com/dry-rb/dry-transformer
53
+ bug_tracker_uri: https://github.com/dry-rb/dry-transformer/issues
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 2.4.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.0.3
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Data transformation toolkit
73
+ test_files: []