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,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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV['COVERAGE'] == 'true'
4
+ require 'codacy-coverage'
5
+ Codacy::Reporter.start
6
+ end
7
+
8
+ begin
9
+ require 'byebug'
10
+ rescue LoadError;end
11
+
12
+ require 'dry/transformer/all'
13
+
14
+ root = Pathname(__FILE__).dirname
15
+ Dir[root.join('support/*.rb').to_s].each { |f| require f }
16
+
17
+ # Namespace holding all objects created during specs
18
+ module Test
19
+ def self.remove_constants
20
+ constants.each(&method(:remove_const))
21
+ end
22
+ end
23
+
24
+ RSpec.configure do |config|
25
+ config.after do
26
+ Test.remove_constants
27
+ end
28
+
29
+ config.disable_monkey_patching!
30
+ config.warnings = true
31
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ RSpec.describe Dry::Transformer::ArrayTransformations do
6
+ describe '.combine' do
7
+ subject(:result) { described_class.t(:combine, mappings)[input] }
8
+
9
+ let(:input) { [[]] }
10
+ let(:mappings) { [] }
11
+
12
+ it { is_expected.to be_a(Array) }
13
+
14
+ it { is_expected.to eq([]) }
15
+
16
+ context 'without groups' do
17
+ let(:input) do
18
+ [
19
+ [
20
+ {name: 'Jane', email: 'jane@doe.org'}.freeze,
21
+ {name: 'Joe', email: 'joe@doe.org'}.freeze
22
+ ].freeze
23
+ ].freeze
24
+ end
25
+
26
+ it { is_expected.to eq input.first }
27
+ end
28
+
29
+ context 'with one group' do
30
+ let(:input) do
31
+ [
32
+ [
33
+ {name: 'Jane', email: 'jane@doe.org'}.freeze,
34
+ {name: 'Joe', email: 'joe@doe.org'}.freeze
35
+ ].freeze,
36
+ [
37
+ [
38
+ {user: 'Jane', title: 'One'}.freeze,
39
+ {user: 'Jane', title: 'Two'}.freeze,
40
+ {user: 'Joe', title: 'Three'}.freeze
41
+ ]
42
+ ]
43
+ ].freeze
44
+ end
45
+ let(:mappings) { [[:tasks, {name: :user}]] }
46
+
47
+ it 'merges hashes from arrays using provided join keys' do
48
+ output = [
49
+ {name: 'Jane', email: 'jane@doe.org', tasks: [
50
+ {user: 'Jane', title: 'One'},
51
+ {user: 'Jane', title: 'Two'}
52
+ ]},
53
+ {name: 'Joe', email: 'joe@doe.org', tasks: [
54
+ {user: 'Joe', title: 'Three'}
55
+ ]}
56
+ ]
57
+ is_expected.to eql(output)
58
+ end
59
+ end
60
+
61
+ context 'with empty nodes' do
62
+ let(:input) do
63
+ [
64
+ [{name: 'Jane', email: 'jane@doe.org'}.freeze].freeze,
65
+ [
66
+ []
67
+ ]
68
+ ].freeze
69
+ end
70
+
71
+ let(:mappings) { [[:tasks, {name: :user}]] }
72
+
73
+ it { is_expected.to eq([{name: 'Jane', email: 'jane@doe.org', tasks: []}]) }
74
+ end
75
+
76
+ context 'with double mapping' do
77
+ let(:input) do
78
+ [
79
+ [
80
+ {name: 'Jane', email: 'jane@doe.org'}.freeze
81
+ ].freeze,
82
+ [
83
+ [
84
+ {user: 'Jane', user_email: 'jane@doe.org', title: 'One'}.freeze,
85
+ {user: 'Jane', user_email: '', title: 'Two'}.freeze
86
+ ].freeze
87
+ ].freeze
88
+ ].freeze
89
+ end
90
+
91
+ let(:mappings) { [[:tasks, {name: :user, email: :user_email}]] }
92
+
93
+ it 'searches by two keys simultaneously' do
94
+ output = [
95
+ {name: 'Jane', email: 'jane@doe.org', tasks: [
96
+ {user: 'Jane', user_email: 'jane@doe.org', title: 'One'}
97
+ ]}
98
+ ]
99
+ is_expected.to eql(output)
100
+ end
101
+ end
102
+
103
+ context 'with non-array argument' do
104
+ let(:input) do
105
+ 123
106
+ end
107
+
108
+ let(:mappings) { [[:page, {page_id: :id}]] }
109
+
110
+ it { is_expected.to eq(123) }
111
+ end
112
+
113
+ context 'with empty nested array' do
114
+ let(:input) do
115
+ [
116
+ [],
117
+ [
118
+ []
119
+ ]
120
+ ]
121
+ end
122
+
123
+ let(:mappings) { [[:menu_items, {id: :menu_id}, [[:page, {page_id: :id}]]]] }
124
+
125
+ it 'does not crash' do
126
+ expect { result }.not_to raise_error
127
+ end
128
+ end
129
+
130
+ context 'with enumerable input' do
131
+ let(:my_enumerator) do
132
+ Class.new do
133
+ include Enumerable
134
+ extend Forwardable
135
+
136
+ def_delegator :@array, :each
137
+
138
+ def initialize(array)
139
+ @array = array
140
+ end
141
+ end
142
+ end
143
+
144
+ let(:input) do
145
+ [
146
+ my_enumerator.new([
147
+ {name: 'Jane', email: 'jane@doe.org'}.freeze,
148
+ {name: 'Joe', email: 'joe@doe.org'}.freeze
149
+ ].freeze),
150
+ my_enumerator.new([
151
+ my_enumerator.new([
152
+ {user: 'Jane', title: 'One'}.freeze,
153
+ {user: 'Jane', title: 'Two'}.freeze,
154
+ {user: 'Joe', title: 'Three'}.freeze
155
+ ].freeze)
156
+ ].freeze)
157
+ ].freeze
158
+ end
159
+ let(:mappings) { [[:tasks, {name: :user}]] }
160
+
161
+ it 'supports enumerables as well' do
162
+ output = [
163
+ {name: 'Jane', email: 'jane@doe.org', tasks: [
164
+ {user: 'Jane', title: 'One'},
165
+ {user: 'Jane', title: 'Two'}
166
+ ]},
167
+ {name: 'Joe', email: 'joe@doe.org', tasks: [
168
+ {user: 'Joe', title: 'Three'}
169
+ ]}
170
+ ]
171
+ is_expected.to eql(output)
172
+ end
173
+ end
174
+
175
+ describe 'integration test' do
176
+ let(:input) do
177
+ [
178
+ [
179
+ {name: 'Jane', email: 'jane@doe.org'},
180
+ {name: 'Joe', email: 'joe@doe.org'}
181
+ ],
182
+ [
183
+ [
184
+ # user tasks
185
+ [
186
+ {user: 'Jane', title: 'One'},
187
+ {user: 'Jane', title: 'Two'},
188
+ {user: 'Joe', title: 'Three'}
189
+ ],
190
+ [
191
+ # task tags
192
+ [
193
+ {task: 'One', tag: 'red'},
194
+ {task: 'Three', tag: 'blue'}
195
+ ]
196
+ ]
197
+ ]
198
+ ]
199
+ ]
200
+ end
201
+
202
+ let(:mappings) { [[:tasks, {name: :user}, [[:tags, title: :task]]]] }
203
+
204
+ it 'merges hashes from arrays using provided join keys' do
205
+ output = [
206
+ {name: 'Jane', email: 'jane@doe.org', tasks: [
207
+ {user: 'Jane', title: 'One', tags: [{task: 'One', tag: 'red'}]},
208
+ {user: 'Jane', title: 'Two', tags: []}
209
+ ]},
210
+ {
211
+ name: 'Joe', email: 'joe@doe.org', tasks: [
212
+ {
213
+ user: 'Joe', title: 'Three', tags: [
214
+ {task: 'Three', tag: 'blue'}
215
+ ]
216
+ }
217
+ ]
218
+ }
219
+ ]
220
+ is_expected.to eql(output)
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Dry::Transformer::ArrayTransformations do
4
+ let(:hashes) { Dry::Transformer::HashTransformations }
5
+
6
+ describe '.extract_key' do
7
+ it 'extracts values by key from all hashes' do
8
+ extract_key = described_class.t(:extract_key, 'name')
9
+
10
+ input = [
11
+ { 'name' => 'Alice', 'role' => 'sender' },
12
+ { 'name' => 'Bob', 'role' => 'receiver' },
13
+ { 'role' => 'listener' }
14
+ ].freeze
15
+
16
+ output = ['Alice', 'Bob', nil]
17
+
18
+ expect(extract_key[input]).to eql(output)
19
+ end
20
+ end
21
+
22
+ it { expect(described_class).not_to be_contain(:extract_key!) }
23
+
24
+ describe '.insert_key' do
25
+ it 'wraps values to tuples with given key' do
26
+ insert_key = described_class.t(:insert_key, 'name')
27
+
28
+ input = ['Alice', 'Bob', nil].freeze
29
+
30
+ output = [
31
+ { 'name' => 'Alice' },
32
+ { 'name' => 'Bob' },
33
+ { 'name' => nil }
34
+ ]
35
+
36
+ expect(insert_key[input]).to eql(output)
37
+ end
38
+ end
39
+
40
+ it { expect(described_class).not_to be_contain(:insert_key!) }
41
+
42
+ describe '.add_keys' do
43
+ it 'returns a new array with missed keys added to tuples' do
44
+ add_keys = described_class.t(:add_keys, [:foo, :bar, :baz])
45
+
46
+ input = [{ foo: 'bar' }, { bar: 'baz' }].freeze
47
+
48
+ output = [
49
+ { foo: 'bar', bar: nil, baz: nil },
50
+ { foo: nil, bar: 'baz', baz: nil }
51
+ ]
52
+
53
+ expect(add_keys[input]).to eql(output)
54
+ end
55
+ end
56
+
57
+ it { expect(described_class).not_to be_contain(:add_keys!) }
58
+
59
+ describe '.map_array' do
60
+ it 'applies funtions to all values' do
61
+ map = described_class.t(:map_array, hashes[:symbolize_keys])
62
+
63
+ input = [
64
+ { 'name' => 'Jane', 'title' => 'One' }.freeze,
65
+ { 'name' => 'Jane', 'title' => 'Two' }.freeze
66
+ ].freeze
67
+
68
+ output = [
69
+ { name: 'Jane', title: 'One' },
70
+ { name: 'Jane', title: 'Two' }
71
+ ]
72
+
73
+ expect(map[input]).to eql(output)
74
+ end
75
+
76
+ it 'handles huge arrays' do
77
+ map = described_class.t(:map_array, hashes[:symbolize_keys])
78
+
79
+ input = Array.new(138_706) { |i| { 'key' => i } }
80
+
81
+ expect { map[input] }.to_not raise_error
82
+ end
83
+
84
+ it 'handles flat value arrays' do
85
+ map = described_class.t(:map_array, :upcase.to_proc)
86
+
87
+ expect(map['foo']).to eql(%w(FOO))
88
+ end
89
+ end
90
+
91
+ it { expect(described_class).not_to be_contain(:map_array!) }
92
+
93
+ describe '.wrap' do
94
+ it 'returns a new array with wrapped hashes' do
95
+ wrap = described_class.t(:wrap, :task, [:title])
96
+
97
+ input = [{ name: 'Jane', title: 'One' }]
98
+ output = [{ name: 'Jane', task: { title: 'One' } }]
99
+
100
+ expect(wrap[input]).to eql(output)
101
+ end
102
+
103
+ it 'returns a array new with deeply wrapped hashes' do
104
+ wrap =
105
+ described_class.t(
106
+ :map_array,
107
+ hashes[:nest, :user, [:name, :title]] +
108
+ hashes[:map_value, :user, hashes[:nest, :task, [:title]]]
109
+ )
110
+
111
+ input = [{ name: 'Jane', title: 'One' }]
112
+ output = [{ user: { name: 'Jane', task: { title: 'One' } } }]
113
+
114
+ expect(wrap[input]).to eql(output)
115
+ end
116
+
117
+ it 'adds data to the existing tuples' do
118
+ wrap = described_class.t(:wrap, :task, [:title])
119
+
120
+ input = [{ name: 'Jane', task: { priority: 1 }, title: 'One' }]
121
+ output = [{ name: 'Jane', task: { priority: 1, title: 'One' } }]
122
+
123
+ expect(wrap[input]).to eql(output)
124
+ end
125
+ end
126
+
127
+ describe '.group' do
128
+ subject(:group) { described_class.t(:group, :tasks, [:title]) }
129
+
130
+ it 'returns a new array with grouped hashes' do
131
+ input = [{ name: 'Jane', title: 'One' }, { name: 'Jane', title: 'Two' }]
132
+ output = [{ name: 'Jane', tasks: [{ title: 'One' }, { title: 'Two' }] }]
133
+
134
+ expect(group[input]).to eql(output)
135
+ end
136
+
137
+ it 'updates the existing group' do
138
+ input = [
139
+ {
140
+ name: 'Jane',
141
+ title: 'One',
142
+ tasks: [{ type: 'one' }, { type: 'two' }]
143
+ },
144
+ {
145
+ name: 'Jane',
146
+ title: 'Two',
147
+ tasks: [{ type: 'one' }, { type: 'two' }]
148
+ }
149
+ ]
150
+ output = [
151
+ {
152
+ name: 'Jane',
153
+ tasks: [
154
+ { title: 'One', type: 'one' },
155
+ { title: 'One', type: 'two' },
156
+ { title: 'Two', type: 'one' },
157
+ { title: 'Two', type: 'two' }
158
+ ]
159
+ }
160
+ ]
161
+
162
+ expect(group[input]).to eql(output)
163
+ end
164
+
165
+ it 'ingnores old values except for array of tuples' do
166
+ input = [
167
+ { name: 'Jane', title: 'One', tasks: [{ priority: 1 }, :wrong] },
168
+ { name: 'Jane', title: 'Two', tasks: :wrong }
169
+ ]
170
+ output = [
171
+ {
172
+ name: 'Jane',
173
+ tasks: [{ title: 'One', priority: 1 }, { title: 'Two' }]
174
+ }
175
+ ]
176
+
177
+ expect(group[input]).to eql(output)
178
+ end
179
+ end
180
+
181
+ describe '.ungroup' do
182
+ subject(:ungroup) { described_class.t(:ungroup, :tasks, [:title]) }
183
+
184
+ it 'returns a new array with ungrouped hashes' do
185
+ input = [{ name: 'Jane', tasks: [{ title: 'One' }, { title: 'Two' }] }]
186
+ output = [{ name: 'Jane', title: 'One' }, { name: 'Jane', title: 'Two' }]
187
+
188
+ expect(ungroup[input]).to eql(output)
189
+ end
190
+
191
+ it 'returns an input with empty array removed' do
192
+ input = [{ name: 'Jane', tasks: [] }]
193
+ output = [{ name: 'Jane' }]
194
+
195
+ expect(ungroup[input]).to eql(output)
196
+ end
197
+
198
+ it 'returns an input when a key is absent' do
199
+ input = [{ name: 'Jane' }]
200
+ output = [{ name: 'Jane' }]
201
+
202
+ expect(ungroup[input]).to eql(output)
203
+ end
204
+
205
+ it 'ungroups array partially' do
206
+ input = [
207
+ {
208
+ name: 'Jane',
209
+ tasks: [
210
+ { title: 'One', type: 'one' },
211
+ { title: 'One', type: 'two' },
212
+ { title: 'Two', type: 'one' },
213
+ { title: 'Two', type: 'two' }
214
+ ]
215
+ }
216
+ ]
217
+ output = [
218
+ {
219
+ name: 'Jane',
220
+ title: 'One',
221
+ tasks: [{ type: 'one' }, { type: 'two' }]
222
+ },
223
+ {
224
+ name: 'Jane',
225
+ title: 'Two',
226
+ tasks: [{ type: 'one' }, { type: 'two' }]
227
+ }
228
+ ]
229
+
230
+ expect(ungroup[input]).to eql(output)
231
+ end
232
+ end
233
+ end