dry-transformer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +12 -0
  3. data/.github/ISSUE_TEMPLATE/----please-don-t-ask-for-support-via-issues.md +10 -0
  4. data/.github/ISSUE_TEMPLATE/---bug-report.md +30 -0
  5. data/.github/ISSUE_TEMPLATE/---feature-request.md +18 -0
  6. data/.github/workflows/custom_ci.yml +66 -0
  7. data/.github/workflows/docsite.yml +34 -0
  8. data/.github/workflows/sync_configs.yml +34 -0
  9. data/.gitignore +16 -0
  10. data/.rspec +4 -0
  11. data/.rubocop.yml +95 -0
  12. data/CHANGELOG.md +3 -0
  13. data/CODE_OF_CONDUCT.md +13 -0
  14. data/CONTRIBUTING.md +29 -0
  15. data/Gemfile +19 -0
  16. data/LICENSE +20 -0
  17. data/README.md +29 -0
  18. data/Rakefile +6 -0
  19. data/docsite/source/built-in-transformations.html.md +47 -0
  20. data/docsite/source/index.html.md +15 -0
  21. data/docsite/source/transformation-objects.html.md +32 -0
  22. data/docsite/source/using-standalone-functions.html.md +82 -0
  23. data/dry-transformer.gemspec +22 -0
  24. data/lib/dry-transformer.rb +3 -0
  25. data/lib/dry/transformer.rb +23 -0
  26. data/lib/dry/transformer/all.rb +11 -0
  27. data/lib/dry/transformer/array.rb +183 -0
  28. data/lib/dry/transformer/array/combine.rb +65 -0
  29. data/lib/dry/transformer/class.rb +56 -0
  30. data/lib/dry/transformer/coercions.rb +196 -0
  31. data/lib/dry/transformer/compiler.rb +47 -0
  32. data/lib/dry/transformer/composite.rb +54 -0
  33. data/lib/dry/transformer/conditional.rb +76 -0
  34. data/lib/dry/transformer/constants.rb +7 -0
  35. data/lib/dry/transformer/error.rb +16 -0
  36. data/lib/dry/transformer/function.rb +109 -0
  37. data/lib/dry/transformer/hash.rb +453 -0
  38. data/lib/dry/transformer/pipe.rb +75 -0
  39. data/lib/dry/transformer/pipe/class_interface.rb +115 -0
  40. data/lib/dry/transformer/pipe/dsl.rb +58 -0
  41. data/lib/dry/transformer/proc.rb +46 -0
  42. data/lib/dry/transformer/recursion.rb +121 -0
  43. data/lib/dry/transformer/registry.rb +150 -0
  44. data/lib/dry/transformer/store.rb +128 -0
  45. data/lib/dry/transformer/version.rb +7 -0
  46. data/spec/spec_helper.rb +31 -0
  47. data/spec/unit/array/combine_spec.rb +224 -0
  48. data/spec/unit/array_transformations_spec.rb +233 -0
  49. data/spec/unit/class_transformations_spec.rb +50 -0
  50. data/spec/unit/coercions_spec.rb +132 -0
  51. data/spec/unit/conditional_spec.rb +48 -0
  52. data/spec/unit/function_not_found_error_spec.rb +12 -0
  53. data/spec/unit/function_spec.rb +193 -0
  54. data/spec/unit/hash_transformations_spec.rb +490 -0
  55. data/spec/unit/proc_transformations_spec.rb +20 -0
  56. data/spec/unit/recursion_spec.rb +145 -0
  57. data/spec/unit/registry_spec.rb +202 -0
  58. data/spec/unit/store_spec.rb +198 -0
  59. data/spec/unit/transformer/class_interface_spec.rb +350 -0
  60. data/spec/unit/transformer/dsl_spec.rb +15 -0
  61. data/spec/unit/transformer/instance_methods_spec.rb +25 -0
  62. metadata +119 -0
@@ -0,0 +1,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