dry-transformer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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