hashformer 0.2.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.
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