hashformer 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/hashformer.rb ADDED
@@ -0,0 +1,87 @@
1
+ # Hashformer: A declarative data transformation DSL for Ruby
2
+ # Created June 2014 by Mike Bourgeous, DeseretBook.com
3
+ # Copyright (C)2014 Deseret Book
4
+ # See LICENSE and README.md for details.
5
+
6
+ require 'classy_hash'
7
+
8
+ require 'hashformer/version'
9
+ require 'hashformer/generate'
10
+
11
+
12
+ # This module contains the Hashformer methods for transforming Ruby Hash objects
13
+ # from one form to another.
14
+ #
15
+ # See README.md for examples.
16
+ module Hashformer
17
+ # Transforms +data+ according to the specification in +xform+. The
18
+ # transformation specification in +xform+ is a Hash specifying an input key
19
+ # name (e.g. a String or Symbol) or transforming lambda for each output key
20
+ # name. If +validate+ is true, then ClassyHash::validate will be used to
21
+ # validate the input and output data formats against the :@__in_schema and
22
+ # :@__out_schema keys within +xform+, if specified.
23
+ #
24
+ # Nested transformations can be specified by calling Hashformer::transform
25
+ # again inside of a lambda.
26
+ #
27
+ # If a value in +xform+ is a Proc, the Proc will be called with the input
28
+ # Hash, and the return value of the Proc used as the output value.
29
+ #
30
+ # If a key in +xform+ is a Proc, the Proc will be called with the exact
31
+ # original input value from +xform+ (before calling a lambda, if applicable)
32
+ # and the input Hash, and the return value of the Proc used as the name of
33
+ # the output key.
34
+ #
35
+ # Example (see the README for more examples):
36
+ # Hashformer.transform({old_name: 'Name'}, {new_name: :old_name}) # Returns {new_name: 'Name'}
37
+ # Hashformer.transform({orig: 5}, {opposite: lambda{|i| -i[:orig]}}) # Returns {opposite: -5}
38
+ def self.transform(data, xform, validate=true)
39
+ raise 'Must transform a Hash' unless data.is_a?(Hash)
40
+ raise 'Transformation must be a Hash' unless xform.is_a?(Hash)
41
+
42
+ validate(data, xform[:__in_schema], 'input') if validate
43
+
44
+ out = {}
45
+ xform.each do |key, value|
46
+ next if key == :__in_schema || key == :__out_schema
47
+
48
+ key = key.call(value, data) if key.respond_to?(:call)
49
+ out[key] = self.get_value(data, value)
50
+ end
51
+
52
+ validate(out, xform[:__out_schema], 'output') if validate
53
+
54
+ out
55
+ end
56
+
57
+ # Returns a value for the given +key+, method chain, or callable on the given
58
+ # +input_hash+.
59
+ def self.get_value(input_hash, key)
60
+ if Hashformer::Generate::Chain::Receiver === key
61
+ # Had to special case chains to allow chaining .call
62
+ key.__chain.call(input_hash)
63
+ elsif key.respond_to?(:call)
64
+ key.call(input_hash)
65
+ else
66
+ input_hash[key]
67
+ end
68
+
69
+ # TODO: add support for nested output hashes
70
+ end
71
+
72
+ private
73
+ # Validates the given data against the given schema, at the given step.
74
+ def self.validate(data, schema, step)
75
+ return unless schema.is_a?(Hash)
76
+
77
+ begin
78
+ ClassyHash.validate(data, schema)
79
+ rescue => e
80
+ raise "#{step} data failed validation: #{e}"
81
+ end
82
+ end
83
+ end
84
+
85
+ if !Kernel.const_defined?(:HF)
86
+ HF = Hashformer
87
+ end
@@ -0,0 +1,231 @@
1
+ # Hashformer transformation generator tests
2
+ # Created July 2014 by Mike Bourgeous, DeseretBook.com
3
+ # Copyright (C)2014 Deseret Book
4
+ # See LICENSE and README.md for details.
5
+
6
+ require 'spec_helper'
7
+
8
+ require 'hashformer'
9
+
10
+ RSpec.describe Hashformer::Generate do
11
+ describe '.map' do
12
+ let(:data) {
13
+ {
14
+ first: 'Hash',
15
+ last: 'Former'
16
+ }
17
+ }
18
+
19
+ context 'map was given no arguments' do
20
+ context 'map was not given a block' do
21
+ it 'returns an empty array' do
22
+ expect(Hashformer.transform(data, { out: HF::G.map() })).to eq({out: []})
23
+ end
24
+ end
25
+
26
+ context 'map was given a block' do
27
+ it 'passes no arguments to the block' do
28
+ expect(Hashformer.transform(data, { out: HF::G.map(){|*a| a.count} })).to eq({out: 0})
29
+ end
30
+ end
31
+ end
32
+
33
+ context 'map was not given a block' do
34
+ let(:xform) {
35
+ {
36
+ name: HF::G.map(:first, :last),
37
+ first: HF::G.map(:first),
38
+ last: HF::G.map(:last)
39
+ }
40
+ }
41
+
42
+ it 'stores mapped values into an array' do
43
+ expect(Hashformer.transform(data, xform)).to eq({name: ['Hash', 'Former'], first: ['Hash'], last: ['Former']})
44
+ end
45
+
46
+ it 'adds nil to a returned array for missing keys' do
47
+ expect(Hashformer.transform({}, xform)).to eq({name: [nil, nil], first: [nil], last: [nil]})
48
+ end
49
+ end
50
+
51
+ context 'map was given a block that returns a string' do
52
+ let(:xform) {
53
+ {
54
+ name: HF::G.map(:first, :last) { |f, l| "#{f} #{l}".strip }
55
+ }
56
+ }
57
+
58
+ it 'generates the expected string for missing keys' do
59
+ expect(Hashformer.transform({}, xform)).to eq({name: ''})
60
+ end
61
+
62
+ it 'generates the expected string' do
63
+ expect(Hashformer.transform(data, xform)).to eq({name: 'Hash Former'})
64
+ end
65
+ end
66
+
67
+ context 'map was given a block that returns a reversed array' do
68
+ let(:xform) {
69
+ {
70
+ name: HF::G.map(:first, :last) { |*a| a.reverse }
71
+ }
72
+ }
73
+
74
+ it 'passes nil for missing keys' do
75
+ expect(Hashformer.transform({}, xform)).to eq({name: [nil, nil]})
76
+ end
77
+
78
+ it 'generates the expected array' do
79
+ expect(Hashformer.transform(data, xform)).to eq({name: ['Former', 'Hash']})
80
+ end
81
+ end
82
+
83
+ context 'map was given callables as keys and no block' do
84
+ let(:xform) {
85
+ {
86
+ name: HF::G.map(->(h){h[:first]}, ->(h){h[:last]})
87
+ }
88
+ }
89
+
90
+ it 'generates the expected output array' do
91
+ expect(Hashformer.transform(data, xform)).to eq({name: ['Hash', 'Former']})
92
+ end
93
+ end
94
+
95
+ context 'map was given paths as keys' do
96
+ let(:xform) {
97
+ {
98
+ name: HF::G.map(HF::G.path[:first], HF::G.path[:last]) { |*a| a.join(' ').downcase }
99
+ }
100
+ }
101
+
102
+ it 'generates the expected output' do
103
+ expect(Hashformer.transform(data, xform)).to eq({name: 'hash former'})
104
+ end
105
+ end
106
+
107
+ context 'map was given method chains as keys and no block' do
108
+ let(:xform) {
109
+ {
110
+ name: HF::G.map(HF::G.chain[:first].downcase, HF::G.chain[:last].upcase)
111
+ }
112
+ }
113
+
114
+ it 'generates the expected output' do
115
+ expect(Hashformer.transform(data, xform)).to eq({name: ['hash', 'FORMER']})
116
+ end
117
+ end
118
+
119
+ it 'works when chained' do
120
+ xform = {
121
+ name: HF::G.map(HF::G.map(:first, :last), HF::G.map(:last, :first))
122
+ }
123
+
124
+ expect(Hashformer.transform(data, xform)).to eq({name: [['Hash', 'Former'], ['Former', 'Hash']]})
125
+ end
126
+
127
+ it 'joins an array using a method reference' do
128
+ data = { a: [1, 2, 3, 4] }
129
+ xform = { a: HF::G.map(:a, &:join) }
130
+ expect(Hashformer.transform(data, xform)).to eq({a: '1234'})
131
+ end
132
+ end
133
+
134
+ describe '.path' do
135
+ let(:data) {
136
+ {
137
+ a: { b: [ 'c', 'd', 'e', 'f' ] }
138
+ }
139
+ }
140
+
141
+ let(:xform) {
142
+ {
143
+ a: HF::G.path[:a][:b][0],
144
+ b: HF::G.path[:a][:b][3]
145
+ }
146
+ }
147
+
148
+ it 'produces the expected output for a simple input' do
149
+ expect(Hashformer.transform(data, xform)).to eq({a: 'c', b: 'f'})
150
+ end
151
+
152
+ it 'returns nil when dereferencing a nonexistent path' do
153
+ expect(Hashformer.transform(data, {a: HF::G.path[:b][:c][0][1][2][3]})).to eq({a: nil})
154
+ end
155
+
156
+ it 'raises an error when dereferencing a non-array/non-hash object' do
157
+ expect{Hashformer.transform(data, {a: HF::G.path[:a][:b][0][:fail]})}.to raise_error(/dereferencing/)
158
+ end
159
+
160
+ context 'no path is added' do
161
+ it 'returns the input hash' do
162
+ expect(Hashformer.transform({a: 1, b: 2}, {x: HF::G.path})).to eq({x: {a: 1, b: 2}})
163
+ end
164
+ end
165
+ end
166
+
167
+ describe '.chain' do
168
+ let(:data) {
169
+ {
170
+ in1: {
171
+ in1: ['a', 'b', 'c', 'd'],
172
+ in2: [1, 2, 3, [4, 5, 6, 7]]
173
+ }
174
+ }
175
+ }
176
+
177
+ let(:xform) {
178
+ xform = {
179
+ out1: HF::G.chain[:in1][:in2][3].reduce(&:+),
180
+ out2: HF[:in1][:in1][3],
181
+ out3: HF[].count
182
+ }
183
+ }
184
+
185
+ it 'produces the expected output for a simple input' do
186
+ expect(Hashformer.transform(data, xform)).to eq({out1: 22, out2: 'd', out3: 1})
187
+ end
188
+
189
+ context 'using normally reserved methods' do
190
+ it 'calls a proc with .call' do
191
+ calldata = {
192
+ p: ->(*a){a.reduce(1, &:*)}
193
+ }
194
+ expect(Hashformer.transform(calldata, {o: HF[:p].call()})).to eq({o: 1})
195
+ expect(Hashformer.transform(calldata, {o: HF[:p].call(5, 0)})).to eq({o: 0})
196
+ expect(Hashformer.transform(calldata, {o: HF[:p].call(5, 4, 5)})).to eq({o: 100})
197
+ end
198
+
199
+ it 'sends messages to the correct target with .send' do
200
+ senddata = {
201
+ a: 'Hashformer'
202
+ }
203
+ sendxf = {
204
+ b: HF[:a].clone.send(:reverse).send(:concat, ' transforms')
205
+ }
206
+ expect(Hashformer.transform(senddata, sendxf)).to eq({b: 'remrofhsaH transforms'})
207
+ end
208
+
209
+ it 'chains operators' do
210
+ opxf = {
211
+ a: !((HF[:a] + 3) == 8),
212
+ }
213
+ expect(Hashformer.transform({a: 5}, opxf)).to eq({a: false})
214
+ expect(Hashformer.transform({a: 6}, opxf)).to eq({a: true})
215
+ end
216
+
217
+ it 'chains instance_exec' do
218
+ class HFTestFoo
219
+ def initialize(val)
220
+ @y = val
221
+ end
222
+ end
223
+ execxf = {
224
+ x: -HF[:a].instance_exec{@y}
225
+ }
226
+ expect(Hashformer.transform({a: HFTestFoo.new(-1.5)}, execxf)).to eq({x: 1.5})
227
+ expect(Hashformer.transform({a: HFTestFoo.new(2014)}, execxf)).to eq({x: -2014})
228
+ end
229
+ end
230
+ end
231
+ end