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.
- checksums.yaml +7 -0
- data/.gitignore +34 -0
- data/.rspec +1 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/README.md +344 -0
- data/hashformer.gemspec +28 -0
- data/lib/hashformer/generate.rb +177 -0
- data/lib/hashformer/version.rb +3 -0
- data/lib/hashformer.rb +87 -0
- data/spec/lib/hashformer/generate_spec.rb +231 -0
- data/spec/lib/hashformer_spec.rb +411 -0
- data/spec/spec_helper.rb +5 -0
- metadata +96 -0
@@ -0,0 +1,411 @@
|
|
1
|
+
# Hashformer: A declarative data transformation DSL for Ruby -- test suite
|
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 'spec_helper'
|
7
|
+
|
8
|
+
require 'hashformer'
|
9
|
+
|
10
|
+
RSpec.describe Hashformer do
|
11
|
+
describe '.validate' do
|
12
|
+
let(:in_schema) {
|
13
|
+
# ClassyHash schema - https://github.com/deseretbook/classy_hash
|
14
|
+
{
|
15
|
+
first: String,
|
16
|
+
last: String,
|
17
|
+
city: [:optional, String],
|
18
|
+
phone: String
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
let(:out_schema) {
|
23
|
+
# ClassyHash schema - https://github.com/deseretbook/classy_hash
|
24
|
+
{
|
25
|
+
name: String,
|
26
|
+
location: String,
|
27
|
+
phone: Integer
|
28
|
+
}
|
29
|
+
}
|
30
|
+
|
31
|
+
it 'processes simple transformations' do
|
32
|
+
expect(Hashformer.transform({}, {})).to eq({})
|
33
|
+
expect(Hashformer.transform({a: 1}, {b: :a})).to eq({b: 1})
|
34
|
+
expect(Hashformer.transform({a: 1, b: 2}, {b: :a, a: :b})).to eq({a: 2, b: 1})
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'processes simple lambda transformations' do
|
38
|
+
expect(Hashformer.transform({a: 1}, {a: ->(x){-x[:a]}})).to eq({a: -1})
|
39
|
+
expect(Hashformer.transform({a: 'hello'}, {a: ->(x){x[:a].length}})).to eq({a: 5})
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'processes multi-value lambda transformations' do
|
43
|
+
expect(Hashformer.transform({a: 'hello', b: 'world'}, {c: ->(x){"#{x[:a].capitalize} #{x[:b]}"}})).to eq({c: 'Hello world'})
|
44
|
+
expect(Hashformer.transform({a: 1, b: 2, c: 3}, {sum: ->(x){x.values.reduce(&:+)}})).to eq({sum: 6})
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'processes lambda keys' do
|
48
|
+
keyindex = {
|
49
|
+
->(value, data){ "key#{data.keys.index(value)}".to_sym } => :x
|
50
|
+
}
|
51
|
+
|
52
|
+
expect(Hashformer.transform({x: 0}, keyindex)).to eq({key0: 0})
|
53
|
+
expect(Hashformer.transform({a: 2, b: 1, x: 0}, keyindex)).to eq({key2: 0})
|
54
|
+
|
55
|
+
keyvaluejoin = {
|
56
|
+
->(value, data){ data[:key] } => :value
|
57
|
+
}
|
58
|
+
|
59
|
+
expect(Hashformer.transform({key: :x, value: -3}, keyvaluejoin)).to eq({x: -3})
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'handles missing input keys' do
|
63
|
+
expect(Hashformer.transform({}, {a: :a})).to eq({a: nil})
|
64
|
+
expect(Hashformer.transform({a: 1}, {a: :a, b: :b, c: :c})).to eq({a: 1, b: nil, c: nil})
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'does not pass values not specified in transformation' do
|
68
|
+
expect(Hashformer.transform({a: 1}, {})).to eq({})
|
69
|
+
expect(Hashformer.transform({a: 1, b: 2, c: 3}, {x: :c})).to eq({x: 3})
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'handles strings as key names' do
|
73
|
+
expect(Hashformer.transform({}, {'a' => :a})).to eq({'a' => nil})
|
74
|
+
expect(Hashformer.transform({a: 1}, {'a' => :a})).to eq({'a' => 1})
|
75
|
+
expect(Hashformer.transform({'a' => 1, 'b' => 2}, {'b' => 'a', 'a' => 'b'})).to eq({'a' => 2, 'b' => 1})
|
76
|
+
end
|
77
|
+
|
78
|
+
context 'input schema is given' do
|
79
|
+
let(:xform) {
|
80
|
+
{
|
81
|
+
__in_schema: in_schema
|
82
|
+
}
|
83
|
+
}
|
84
|
+
|
85
|
+
it 'accepts valid input hashes' do
|
86
|
+
expect {
|
87
|
+
Hashformer.transform({first: 'Hello', last: 'World', city: 'Here', phone: '1-2-3-4-5'}, xform)
|
88
|
+
}.not_to raise_error
|
89
|
+
end
|
90
|
+
|
91
|
+
context 'validate is true' do
|
92
|
+
it 'rejects invalid input hashes' do
|
93
|
+
expect {
|
94
|
+
Hashformer.transform({}, xform)
|
95
|
+
}.to raise_error(/present/)
|
96
|
+
|
97
|
+
expect {
|
98
|
+
Hashformer.transform({first: :last}, xform)
|
99
|
+
}.to raise_error(/first/)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
context 'validate is false' do
|
104
|
+
it 'accepts invalid input hashes' do
|
105
|
+
expect {
|
106
|
+
Hashformer.transform({}, xform, false)
|
107
|
+
}.not_to raise_error
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
context 'output schema is given' do
|
113
|
+
let(:xform) {
|
114
|
+
{
|
115
|
+
__out_schema: out_schema,
|
116
|
+
|
117
|
+
name: lambda {|data| "#{data[:first]} #{data[:last]}".strip },
|
118
|
+
location: :city,
|
119
|
+
phone: lambda {|data| data[:phone].gsub(/[^\d]/, '').to_i }
|
120
|
+
}
|
121
|
+
}
|
122
|
+
|
123
|
+
it 'accepts valid output hashes' do
|
124
|
+
expect {
|
125
|
+
Hashformer.transform({city: '', phone: ''}, xform)
|
126
|
+
}.not_to raise_error
|
127
|
+
end
|
128
|
+
|
129
|
+
context 'validate is true' do
|
130
|
+
it 'rejects invalid output hashes' do
|
131
|
+
expect {
|
132
|
+
Hashformer.transform({city: 17, phone: ''}, xform)
|
133
|
+
}.to raise_error(/location/)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
context 'validate is false' do
|
138
|
+
it 'accepts invalid output hashes' do
|
139
|
+
expect {
|
140
|
+
Hashformer.transform({city: 17, phone: ''}, xform, false)
|
141
|
+
}.not_to raise_error
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
context 'both input and output schema are given' do
|
147
|
+
let(:xform) {
|
148
|
+
{
|
149
|
+
__in_schema: in_schema,
|
150
|
+
__out_schema: out_schema,
|
151
|
+
|
152
|
+
name: lambda {|data| "#{data[:first]} #{data[:last]}".strip },
|
153
|
+
location: :city,
|
154
|
+
phone: lambda {|data| data[:phone].gsub(/[^\d]/, '').to_i }
|
155
|
+
}
|
156
|
+
}
|
157
|
+
|
158
|
+
it 'transforms valid data correctly' do
|
159
|
+
expect(Hashformer.transform(
|
160
|
+
{
|
161
|
+
first: 'Hash',
|
162
|
+
last: 'Transformed',
|
163
|
+
city: 'Here',
|
164
|
+
phone: '555-555-5555'
|
165
|
+
},
|
166
|
+
xform
|
167
|
+
)).to eq({name: 'Hash Transformed', location: 'Here', phone: 5555555555})
|
168
|
+
end
|
169
|
+
|
170
|
+
it 'rejects invalid input' do
|
171
|
+
expect{
|
172
|
+
Hashformer.transform({}, xform)
|
173
|
+
}.to raise_error(/present/)
|
174
|
+
end
|
175
|
+
|
176
|
+
it 'rejects invalid output' do
|
177
|
+
expect{
|
178
|
+
Hashformer.transform({first: 'Hello', last: 'There', phone: '555-555-5555'}, xform)
|
179
|
+
}.to raise_error(/output data failed/)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
context 'README examples' do
|
184
|
+
context 'Basic renaming' do
|
185
|
+
it 'produces the expected output for string keys' do
|
186
|
+
data = {
|
187
|
+
'first_name' => 'Hash',
|
188
|
+
'last_name' => 'Former'
|
189
|
+
}
|
190
|
+
xform = {
|
191
|
+
first: 'first_name',
|
192
|
+
last: 'last_name'
|
193
|
+
}
|
194
|
+
|
195
|
+
expect(Hashformer.transform(data, xform)).to eq({first: 'Hash', last: 'Former'})
|
196
|
+
end
|
197
|
+
|
198
|
+
it 'produces the expected output for integer keys' do
|
199
|
+
data = {
|
200
|
+
0 => 'Nothing',
|
201
|
+
1 => 'Only One'
|
202
|
+
}
|
203
|
+
xform = {
|
204
|
+
zero: 0,
|
205
|
+
one: 1
|
206
|
+
}
|
207
|
+
|
208
|
+
expect(Hashformer.transform(data, xform)).to eq({zero: 'Nothing', one: 'Only One'})
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
context 'Nested values' do
|
213
|
+
it 'produces the expected output for a present path' do
|
214
|
+
data = {
|
215
|
+
name: 'Hashformer',
|
216
|
+
addresses: [
|
217
|
+
{
|
218
|
+
line1: 'Hash',
|
219
|
+
line2: 'Former'
|
220
|
+
}
|
221
|
+
]
|
222
|
+
}
|
223
|
+
xform = {
|
224
|
+
name: :name,
|
225
|
+
line1: HF::G.path[:addresses][0][:line1],
|
226
|
+
line2: HF::G.path[:addresses][0][:line2]
|
227
|
+
}
|
228
|
+
|
229
|
+
expect(Hashformer.transform(data, xform)).to eq({name: 'Hashformer', line1: 'Hash', line2: 'Former'})
|
230
|
+
end
|
231
|
+
|
232
|
+
it 'produces the expected output for a missing path' do
|
233
|
+
data = {
|
234
|
+
a: { b: 'c' }
|
235
|
+
}
|
236
|
+
xform = {
|
237
|
+
a: HF::G.path[:a][0][:c]
|
238
|
+
}
|
239
|
+
|
240
|
+
expect(Hashformer.transform(data, xform)).to eq({a: nil})
|
241
|
+
end
|
242
|
+
|
243
|
+
it 'returns the entire hash if no path is given' do
|
244
|
+
data = {
|
245
|
+
a: 1,
|
246
|
+
b: 2
|
247
|
+
}
|
248
|
+
xform = {
|
249
|
+
h: HF::G.path
|
250
|
+
}
|
251
|
+
|
252
|
+
expect(Hashformer.transform(data, xform)).to eq({h: {a: 1, b: 2}})
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
context 'Method chaining' do
|
257
|
+
it 'produces the expected output for simple method chaining' do
|
258
|
+
data = {
|
259
|
+
s: 'Hashformer',
|
260
|
+
v: [1, 2, 3, 4, 5]
|
261
|
+
}
|
262
|
+
xform = {
|
263
|
+
s: HF[:s].reverse.capitalize,
|
264
|
+
# It's important to call clone before calling methods that modify the array
|
265
|
+
v: HF[:v].clone.concat([6]).map{|x| x * x}.reduce(0, &:+)
|
266
|
+
}
|
267
|
+
|
268
|
+
expect(Hashformer.transform(data, xform)).to eq({s: 'Remrofhsah', v: 91})
|
269
|
+
end
|
270
|
+
|
271
|
+
it 'raises an exception if accessing beyond a missing path' do
|
272
|
+
data = {
|
273
|
+
a: [1, 2, 3]
|
274
|
+
}
|
275
|
+
xform = {
|
276
|
+
a: HF[:b][0]
|
277
|
+
}
|
278
|
+
|
279
|
+
expect{Hashformer.transform(data, xform)}.to raise_error(/\[\]/)
|
280
|
+
end
|
281
|
+
|
282
|
+
it 'returns the input hash with no methods added' do
|
283
|
+
data = {
|
284
|
+
a: 1
|
285
|
+
}
|
286
|
+
xform = {
|
287
|
+
a: HF[].count,
|
288
|
+
b: HF::G.chain
|
289
|
+
}
|
290
|
+
|
291
|
+
expect(Hashformer.transform(data, xform)).to eq({a: 1, b: {a: 1}})
|
292
|
+
end
|
293
|
+
|
294
|
+
it 'produces the expected output for chained operators' do
|
295
|
+
xform = {
|
296
|
+
x: -(HF[:x] * 2) + 5
|
297
|
+
}
|
298
|
+
|
299
|
+
expect(Hashformer.transform({x: 3}, xform)).to eq({x: -1})
|
300
|
+
expect(Hashformer.transform({x: -12}, xform)).to eq({x: 29})
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
context 'Mapping one or more values' do
|
305
|
+
it 'produces the expected output for a single map parameter' do
|
306
|
+
data = {
|
307
|
+
a: 'Hashformer'
|
308
|
+
}
|
309
|
+
xform = {
|
310
|
+
a: HF::G.map(:a, &:upcase),
|
311
|
+
b: HF::G.map(:a)
|
312
|
+
}
|
313
|
+
|
314
|
+
expect(Hashformer.transform(data, xform)).to eq({a: 'HASHFORMER', b: ['Hashformer']})
|
315
|
+
end
|
316
|
+
|
317
|
+
it 'produces the expected output for a map/reduce' do
|
318
|
+
data = {
|
319
|
+
items: [
|
320
|
+
{name: 'Item 1', price: 1.50},
|
321
|
+
{name: 'Item 2', price: 2.50},
|
322
|
+
{name: 'Item 3', price: 3.50},
|
323
|
+
{name: 'Item 4', price: 4.50},
|
324
|
+
],
|
325
|
+
shipping: 5.50
|
326
|
+
}
|
327
|
+
xform = {
|
328
|
+
item_total: HF[:items].map{|i| i[:price]}.reduce(0.0, &:+),
|
329
|
+
total: HF::G.map(HF[:items].map{|i| i[:price]}.reduce(0.0, &:+), HF::G.path[:shipping], &:+)
|
330
|
+
}
|
331
|
+
|
332
|
+
expect(Hashformer.transform(data, xform)).to eq({item_total: 12.0, total: 17.5})
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
context 'Lambda processing' do
|
337
|
+
it 'produces the expected output for the Pythagorean distance equation' do
|
338
|
+
data = {
|
339
|
+
x: 3.0,
|
340
|
+
y: 4.0
|
341
|
+
}
|
342
|
+
xform = {
|
343
|
+
radius: ->(h){ Math.sqrt(h[:x] * h[:x] + h[:y] * h[:y]) }
|
344
|
+
}
|
345
|
+
|
346
|
+
expect(Hashformer.transform(data, xform)).to eq({radius: 5.0})
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
context 'Dynamic key names' do
|
351
|
+
it 'produces the expected output for a dynamic key' do
|
352
|
+
data = {
|
353
|
+
key: :x,
|
354
|
+
value: 0
|
355
|
+
}
|
356
|
+
xform = {
|
357
|
+
->(value, h){h[:key]} => :value
|
358
|
+
}
|
359
|
+
|
360
|
+
expect(Hashformer.transform(data, xform)).to eq({x: 0})
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
context 'Practical example with validation' do
|
365
|
+
it 'produces the expected output for a practical example' do
|
366
|
+
# Classy Hash schema - https://github.com/deseretbook/classy_hash
|
367
|
+
in_schema = {
|
368
|
+
# Totally violates http://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/
|
369
|
+
first: String,
|
370
|
+
last: String,
|
371
|
+
city: String,
|
372
|
+
phone: String,
|
373
|
+
}
|
374
|
+
|
375
|
+
out_schema = {
|
376
|
+
name: String,
|
377
|
+
location: String,
|
378
|
+
phone: Integer, # Just for example; probably shouldn't make phone numbers integers
|
379
|
+
}
|
380
|
+
|
381
|
+
# Hashformer transformation - https://github.com/deseretbook/hashformer
|
382
|
+
xform = {
|
383
|
+
# Validate input and output data according to the Classy Hash schemas
|
384
|
+
__in_schema: in_schema,
|
385
|
+
__out_schema: out_schema,
|
386
|
+
|
387
|
+
# Combine first and last name into a single String
|
388
|
+
name: HF::G.map(:first, :last) {|f, l| "#{f} #{l}".strip},
|
389
|
+
|
390
|
+
# Copy the :city field directly into :location
|
391
|
+
location: :city,
|
392
|
+
|
393
|
+
# Remove non-digits from :phone
|
394
|
+
phone: HF[:phone].gsub(/[^\d]/, '').to_i
|
395
|
+
}
|
396
|
+
|
397
|
+
data = {
|
398
|
+
first: 'Hash',
|
399
|
+
last: 'Transformed',
|
400
|
+
city: 'Here',
|
401
|
+
phone: '555-555-5555',
|
402
|
+
}
|
403
|
+
|
404
|
+
expected = {name: 'Hash Transformed', location: 'Here', phone: 5555555555}
|
405
|
+
|
406
|
+
expect(Hashformer.transform(data, xform)).to eq(expected)
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
end
|
411
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: hashformer
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Deseret Book
|
8
|
+
- Mike Bourgeous
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2014-07-16 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - "~>"
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '1.5'
|
21
|
+
type: :development
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - "~>"
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '1.5'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: classy_hash
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - "~>"
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '0.1'
|
35
|
+
- - ">="
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 0.1.1
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
requirements:
|
42
|
+
- - "~>"
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0.1'
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.1.1
|
48
|
+
description: |2
|
49
|
+
Hashformer provides a simple, Ruby Hash-based way of transforming data from
|
50
|
+
one format to another. It's vaguely like XSLT, but way less complicated
|
51
|
+
and way more Ruby.
|
52
|
+
email:
|
53
|
+
- mike@mikebourgeous.com
|
54
|
+
executables: []
|
55
|
+
extensions: []
|
56
|
+
extra_rdoc_files: []
|
57
|
+
files:
|
58
|
+
- ".gitignore"
|
59
|
+
- ".rspec"
|
60
|
+
- Gemfile
|
61
|
+
- LICENSE
|
62
|
+
- README.md
|
63
|
+
- hashformer.gemspec
|
64
|
+
- lib/hashformer.rb
|
65
|
+
- lib/hashformer/generate.rb
|
66
|
+
- lib/hashformer/version.rb
|
67
|
+
- spec/lib/hashformer/generate_spec.rb
|
68
|
+
- spec/lib/hashformer_spec.rb
|
69
|
+
- spec/spec_helper.rb
|
70
|
+
homepage: https://github.com/deseretbook/hashformer
|
71
|
+
licenses: []
|
72
|
+
metadata: {}
|
73
|
+
post_install_message:
|
74
|
+
rdoc_options: []
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 1.9.3
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
requirements: []
|
88
|
+
rubyforge_project:
|
89
|
+
rubygems_version: 2.2.2
|
90
|
+
signing_key:
|
91
|
+
specification_version: 4
|
92
|
+
summary: Transform any Hash with a declarative data transformation DSL for Ruby
|
93
|
+
test_files:
|
94
|
+
- spec/lib/hashformer/generate_spec.rb
|
95
|
+
- spec/lib/hashformer_spec.rb
|
96
|
+
- spec/spec_helper.rb
|