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
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
|