Skip to content

Commit 8dcdf91

Browse files
committed
Add HashCoercer built-in coercer
1 parent 2869cbb commit 8dcdf91

File tree

8 files changed

+150
-9
lines changed

8 files changed

+150
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Unreleased
99
### Added
1010
- `RangeOf` built-in validator, for validating `Range` objects
11+
- `HashCoercer` built-in coercer for homogeneous `Hash` objects
1112
### Changed
1213
- The exceptions `ValueSemantics::MissingAttributes` and
1314
`ValueSemantics::InvalidValue` are now raised from inside

README.eceval.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,34 @@ For example, the default value could be a string,
354354
which would then be coerced into an `Pathname` object.
355355
356356
357+
## Built-in Coercers
358+
359+
ValueSemantics provides a few built-in coercer objects via the DSL.
360+
361+
```ruby
362+
class Config
363+
include ValueSemantics.for_attributes {
364+
# ArrayCoercer: takes an element coercer
365+
paths coerce: ArrayCoercer(Pathname.method(:new))
366+
367+
# HashCoercer: takes a key and value coercer
368+
env coerce: HashCoercer(
369+
keys: :to_sym.to_proc,
370+
values: :to_i.to_proc,
371+
)
372+
}
373+
end
374+
375+
config = Config.new(
376+
paths: ['/a', '/b'],
377+
env: { 'AAAA' => '1', 'BBBB' => '2' },
378+
)
379+
380+
config.paths #=>
381+
config.env #=>
382+
```
383+
384+
357385
## Nesting
358386
359387
It is fairly common to nest value objects inside each other. This

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,34 @@ For example, the default value could be a string,
358358
which would then be coerced into an `Pathname` object.
359359
360360
361+
## Built-in Coercers
362+
363+
ValueSemantics provides a few built-in coercer objects via the DSL.
364+
365+
```ruby
366+
class Config
367+
include ValueSemantics.for_attributes {
368+
# ArrayCoercer: takes an element coercer
369+
paths coerce: ArrayCoercer(Pathname.method(:new))
370+
371+
# HashCoercer: takes a key and value coercer
372+
env coerce: HashCoercer(
373+
keys: :to_sym.to_proc,
374+
values: :to_i.to_proc,
375+
)
376+
}
377+
end
378+
379+
config = Config.new(
380+
paths: ['/a', '/b'],
381+
env: { 'AAAA' => '1', 'BBBB' => '2' },
382+
)
383+
384+
config.paths #=> [#<Pathname:/a>, #<Pathname:/b>]
385+
config.env #=> {:AAAA=>1, :BBBB=>2}
386+
```
387+
388+
361389
## Nesting
362390
363391
It is fairly common to nest value objects inside each other. This

lib/value_semantics.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
dsl
99
either
1010
hash_of
11+
hash_coercer
1112
instance_methods
1213
range_of
1314
recipe

lib/value_semantics/dsl.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ def ArrayCoercer(element_coercer)
6060
ArrayCoercer.new(element_coercer)
6161
end
6262

63+
IDENTITY_COERCER = :itself.to_proc
64+
def HashCoercer(keys: IDENTITY_COERCER, values: IDENTITY_COERCER)
65+
HashCoercer.new(key_coercer: keys, value_coercer: values)
66+
end
67+
6368
#
6469
# Defines one attribute.
6570
#
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module ValueSemantics
2+
class HashCoercer
3+
attr_reader :key_coercer, :value_coercer
4+
5+
def initialize(key_coercer:, value_coercer:)
6+
@key_coercer, @value_coercer = key_coercer, value_coercer
7+
freeze
8+
end
9+
10+
def call(obj)
11+
hash = coerce_to_hash(obj)
12+
return obj unless hash
13+
14+
{}.tap do |result|
15+
hash.each do |key, value|
16+
r_key = key_coercer.(key)
17+
r_value = value_coercer.(value)
18+
result[r_key] = r_value
19+
end
20+
end
21+
end
22+
23+
private
24+
25+
def coerce_to_hash(obj)
26+
return nil unless obj.respond_to?(:to_h)
27+
obj.to_h
28+
end
29+
end
30+
end

spec/dsl_spec.rb

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,17 @@
4343
expect(validator).to be === %w(1 2 3)
4444
end
4545

46-
it 'has a built-in HashOf matcher' do
47-
validator = subject.HashOf(Symbol => Integer)
48-
expect(validator).to be === {a: 2, b:2}
49-
end
50-
51-
it 'raises ArgumentError if the HashOf argument is wrong' do
52-
expect { subject.HashOf({a: 1, b: 2}) }.to raise_error(ArgumentError,
53-
"HashOf() takes a hash with one key and one value",
54-
)
46+
context 'built-in HashOf matcher' do
47+
it 'matches hashes' do
48+
validator = subject.HashOf(Symbol => Integer)
49+
expect(validator).to be === {a: 2, b:2}
50+
end
51+
52+
it 'raises ArgumentError if the argument is wrong' do
53+
expect { subject.HashOf({a: 1, b: 2}) }.to raise_error(ArgumentError,
54+
"HashOf() takes a hash with one key and one value",
55+
)
56+
end
5557
end
5658

5759
it 'has a built-in RangeOf matcher' do
@@ -69,6 +71,21 @@
6971
expect(subject.__attributes.first.name).to eq(:else)
7072
end
7173

74+
context 'built-in HashCoercer coercer' do
75+
it 'allows anything for keys/values by default' do
76+
coercer = subject.HashCoercer()
77+
expect(coercer.({whatever: 42})).to eq({whatever: 42})
78+
end
79+
80+
it 'can take coercers for keys and values' do
81+
coercer = subject.HashCoercer(
82+
keys: ->(x) { x.to_sym },
83+
values: ->(x) { x.to_i },
84+
)
85+
expect(coercer.({'x' => '1'})).to eq({x: 1})
86+
end
87+
end
88+
7289
it "produces a frozen recipe with DSL.run" do
7390
recipe = described_class.run { whatever }
7491

spec/hash_coercer_spec.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
RSpec.describe ValueSemantics::HashCoercer do
2+
subject do
3+
described_class.new(
4+
key_coercer: ->(x) { x.to_sym },
5+
value_coercer: ->(x) { x.to_i },
6+
)
7+
end
8+
9+
it { is_expected.to be_frozen }
10+
11+
it 'coerces hash-like objects to hashes' do
12+
hashlike = double(to_h: {a: 1})
13+
expect(subject.(hashlike)).to eq({a: 1})
14+
end
15+
16+
it 'returns non-hash-like objects, unchanged' do
17+
expect(subject.(5)).to eq(5)
18+
end
19+
20+
it 'allows empty hashes' do
21+
expect(subject.({})).to eq({})
22+
end
23+
24+
it 'coerces hash keys' do
25+
expect(subject.({'a' => 1})).to eq({a: 1})
26+
end
27+
28+
it 'coerces hash values' do
29+
expect(subject.({a: '1'})).to eq({a: 1})
30+
end
31+
end

0 commit comments

Comments
 (0)