Skip to content

Commit 99bcd25

Browse files
committed
♻️ Refactor to support either string or symbol keyed Snakes
1 parent 11725e0 commit 99bcd25

File tree

15 files changed

+297
-190
lines changed

15 files changed

+297
-190
lines changed

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ GEM
3535
rspec-core (~> 3.11.0)
3636
rspec-expectations (~> 3.11.0)
3737
rspec-mocks (~> 3.11.0)
38+
rspec-block_is_expected (1.0.2)
39+
rspec-core
3840
rspec-core (3.11.0)
3941
rspec-support (~> 3.11.0)
4042
rspec-expectations (3.11.0)
@@ -97,6 +99,7 @@ DEPENDENCIES
9799
rake (~> 13.0)
98100
redcarpet
99101
rspec (~> 3.0)
102+
rspec-block_is_expected
100103
rubocop-lts (~> 8.0)
101104
rubocop-md
102105
rubocop-performance

lib/snaky_hash.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
require_relative "snaky_hash/version"
88
require_relative "snaky_hash/snake"
9+
require_relative "snaky_hash/string_keyed"
10+
require_relative "snaky_hash/symbol_keyed"
911

12+
# This is the namespace for this gem
1013
module SnakyHash
1114
end
1215

lib/snaky_hash/snake.rb

Lines changed: 64 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,72 @@
1-
require "hashie/mash"
2-
1+
# This is a module-class hybrid.
2+
#
3+
# Hashie's standard SymbolizeKeys is similar to the functionality we want.
4+
# ... but not quite. We need to support both String (for oauth2) and Symbol keys (for oauth).
5+
# include Hashie::Extensions::Mash::SymbolizeKeys
36
module SnakyHash
4-
class Snake < Hashie::Mash
5-
# This is similar to the functionality we want.
6-
# include Hashie::Extensions::Mash::SymbolizeKeys
7-
8-
protected
9-
10-
# Converts a key to a string,
11-
# but only if it is able to be converted to a symbol.
12-
#
13-
# @api private
14-
# @param [<K>] key the key to attempt convert to a symbol
15-
# @return [String, K]
16-
def convert_key(key)
17-
key.respond_to?(:to_sym) ? underscore_string(key.to_s) : key
7+
class Snake < Module
8+
def initialize(key_type: :string)
9+
super()
10+
@key_type = key_type
1811
end
1912

20-
# Unlike its parent Mash, a SnakyHash::Snake will convert other
21-
# Hashie::Hash values to a SnakyHash::Snake when assigning
22-
# instead of respecting the existing subclass
23-
def convert_value(val, duping = false) #:nodoc:
24-
case val
25-
when self.class
26-
val.dup
27-
when ::Hash
28-
val = val.dup if duping
29-
self.class.new(val)
30-
when ::Array
31-
val.collect { |e| convert_value(e) }
32-
else
33-
val
34-
end
13+
def included(base)
14+
conversions_module = SnakyModulizer.to_mod(@key_type)
15+
base.include(conversions_module)
3516
end
3617

37-
# converts a camel_cased string to a underscore string
38-
# subs spaces with underscores, strips whitespace
39-
# Same way ActiveSupport does string.underscore
40-
def underscore_string(str)
41-
str.to_s.strip
42-
.tr(" ", "_")
43-
.gsub(/::/, "/")
44-
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
45-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
46-
.tr("-", "_")
47-
.squeeze("_")
48-
.downcase
18+
module SnakyModulizer
19+
def self.to_mod(key_type)
20+
Module.new do
21+
# Converts a key to a symbol, or a string, depending on key_type,
22+
# but only if it is able to be converted to a symbol,
23+
# and after underscoring it.
24+
#
25+
# @api private
26+
# @param [<K>] key the key to attempt convert to a symbol
27+
# @return [Symbol, K]
28+
29+
case key_type
30+
when :string then
31+
define_method(:convert_key) { |key| key.respond_to?(:to_sym) ? underscore_string(key.to_s) : key }
32+
when :symbol then
33+
define_method(:convert_key) { |key| key.respond_to?(:to_sym) ? underscore_string(key.to_s).to_sym : key }
34+
else
35+
raise ArgumentError, "SnakyHash: Unhandled key_type: #{key_type}"
36+
end
37+
38+
# Unlike its parent Mash, a SnakyHash::Snake will convert other
39+
# Hashie::Hash values to a SnakyHash::Snake when assigning
40+
# instead of respecting the existing subclass
41+
define_method :convert_value do |val, duping = false| #:nodoc:
42+
case val
43+
when self.class
44+
val.dup
45+
when ::Hash
46+
val = val.dup if duping
47+
self.class.new(val)
48+
when ::Array
49+
val.collect { |e| convert_value(e) }
50+
else
51+
val
52+
end
53+
end
54+
55+
# converts a camel_cased string to a underscore string
56+
# subs spaces with underscores, strips whitespace
57+
# Same way ActiveSupport does string.underscore
58+
define_method :underscore_string do |str|
59+
str.to_s.strip
60+
.tr(" ", "_")
61+
.gsub(/::/, "/")
62+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
63+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
64+
.tr("-", "_")
65+
.squeeze("_")
66+
.downcase
67+
end
68+
end
69+
end
4970
end
5071
end
5172
end

lib/snaky_hash/string_keyed.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module SnakyHash
2+
class StringKeyed < Hashie::Mash
3+
include SnakyHash::Snake.new(key_type: :string)
4+
end
5+
end

lib/snaky_hash/symbol_keyed.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module SnakyHash
2+
class SymbolKeyed < Hashie::Mash
3+
include SnakyHash::Snake.new(key_type: :symbol)
4+
end
5+
end

snaky_hash.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ Gem::Specification.new do |spec|
4141
spec.add_development_dependency "rake", ">= 12"
4242
spec.add_development_dependency "rspec", ">= 3"
4343
spec.add_development_dependency "rubocop-lts", "~> 8.0"
44+
spec.add_development_dependency "rspec-block_is_expected"
4445
end

spec/shared_contexts/base_hash.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.shared_context "base hash" do
4+
let(:base_hash) do
5+
{
6+
"varOne" => 1,
7+
"two" => 2,
8+
:three => 3,
9+
:varFour => 4,
10+
"fiveHumpHumps" => 5,
11+
:nested => {
12+
"NestedOne" => "One",
13+
:two => "two",
14+
"nested_three" => "three"
15+
},
16+
"nestedTwo" => {
17+
"nested_two" => 22,
18+
:nestedThree => 23
19+
},
20+
:nestedThree => [
21+
{ nestedFour: 4 },
22+
{ "nestedFour" => 4 }
23+
],
24+
"spaced Key" => "When would this happen?",
25+
"trailing spaces " => "better safe than sorry",
26+
"extra spaces" => "hopefully this never happens",
27+
4 => "not symbolizable"
28+
}
29+
end
30+
end
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.shared_examples_for "a snaked hash" do
4+
include_context "base hash"
5+
6+
it { is_expected.to be_a(Hashie::Mash) }
7+
8+
it "creates a new rash where all the keys are underscored instead of camelcased" do
9+
expect(subject.var_one).to eq(1)
10+
expect(subject.two).to eq(2)
11+
expect(subject.three).to eq(3)
12+
expect(subject.var_four).to eq(4)
13+
expect(subject.five_hump_humps).to eq(5)
14+
expect(subject.nested).to be_a(subject.class)
15+
expect(subject.nested.nested_one).to eq("One")
16+
expect(subject.nested.two).to eq("two")
17+
expect(subject.nested.nested_three).to eq("three")
18+
expect(subject.nested_two).to be_a(subject.class)
19+
expect(subject.nested_two.nested_two).to eq(22)
20+
expect(subject.nested_two.nested_three).to eq(23)
21+
expect(subject.spaced_key).to eq("When would this happen?")
22+
expect(subject.trailing_spaces).to eq("better safe than sorry")
23+
expect(subject.extra_spaces).to eq("hopefully this never happens")
24+
end
25+
26+
it "allows camelCased accessors" do
27+
# avoiding hashie v5- warnings
28+
subject.class.disable_warnings(:varOne) if Gem::Version.new(Hashie::VERSION) >= Gem::Version.new("5.0.0")
29+
30+
expect(subject.varOne).to eq(1)
31+
subject.varOne = "once"
32+
expect(subject.varOne).to eq("once")
33+
expect(subject.var_one).to eq("once")
34+
end
35+
36+
it "allows camelCased accessors on nested hashes" do
37+
# avoiding hashie v5- warnings
38+
subject.class.disable_warnings(:nestedOne) if Gem::Version.new(Hashie::VERSION) >= Gem::Version.new("5.0.0")
39+
40+
expect(subject.nested.nestedOne).to eq("One")
41+
subject.nested.nestedOne = "once"
42+
expect(subject.nested.nested_one).to eq("once")
43+
end
44+
45+
it "merges well with a Mash" do
46+
merged = subject.merge Hashie::Mash.new(
47+
nested: { fourTimes: "a charm" },
48+
nested3: { helloWorld: "hi" }
49+
)
50+
51+
expect(merged.nested.four_times).to eq("a charm")
52+
expect(merged.nested.fourTimes).to eq("a charm")
53+
expect(merged.nested3).to be_a(subject.class)
54+
expect(merged.nested3.hello_world).to eq("hi")
55+
expect(merged.nested3.helloWorld).to eq("hi")
56+
expect(merged[:nested3][:helloWorld]).to eq("hi")
57+
end
58+
59+
it "updates well with a Mash" do
60+
subject.update Hashie::Mash.new(
61+
nested: { fourTimes: "a charm" },
62+
nested3: { helloWorld: "hi" }
63+
)
64+
65+
expect(subject.nested.four_times).to eq("a charm")
66+
expect(subject.nested.fourTimes).to eq("a charm")
67+
expect(subject.nested3).to be_a(subject.class)
68+
expect(subject.nested3.hello_world).to eq("hi")
69+
expect(subject.nested3.helloWorld).to eq("hi")
70+
expect(subject[:nested3][:helloWorld]).to eq("hi")
71+
end
72+
73+
it "merges well with a Hash" do
74+
merged = subject.merge(
75+
nested: { fourTimes: "work like a charm" },
76+
nested3: { helloWorld: "hi" }
77+
)
78+
79+
expect(merged.nested.four_times).to eq("work like a charm")
80+
expect(merged.nested.fourTimes).to eq("work like a charm")
81+
expect(merged.nested3).to be_a(subject.class)
82+
expect(merged.nested3.hello_world).to eq("hi")
83+
expect(merged.nested3.helloWorld).to eq("hi")
84+
expect(merged[:nested3][:helloWorld]).to eq("hi")
85+
end
86+
87+
it "handles assigning a new Hash and convert it to a rash" do
88+
subject.nested3 = { helloWorld: "hi" }
89+
90+
expect(subject.nested3).to be_a(subject.class)
91+
expect(subject.nested3.hello_world).to eq("hi")
92+
expect(subject.nested3.helloWorld).to eq("hi")
93+
expect(subject[:nested3][:helloWorld]).to eq("hi")
94+
end
95+
96+
it "converts an array of Hashes" do
97+
expect(subject.nested_three).to be_a(Array)
98+
expect(subject.nested_three[0]).to be_a(subject.class)
99+
expect(subject.nested_three[0].nested_four).to eq(4)
100+
expect(subject.nested_three[1]).to be_a(subject.class)
101+
expect(subject.nested_three[1].nested_four).to eq(4)
102+
end
103+
104+
it "allows initializing reader" do
105+
subject.nested3!.helloWorld = "hi"
106+
expect(subject.nested3.hello_world).to eq("hi")
107+
end
108+
109+
it "does not transform non-Symbolizable keys" do
110+
expect(subject[4]).to eq("not symbolizable")
111+
expect(subject[:"4"]).to be_nil
112+
expect(subject["4"]).to be_nil
113+
expect(subject.key?("4")).to be false
114+
expect(subject.key?(:"4")).to be false
115+
expect(subject.key?(4)).to be true
116+
end
117+
end

spec/snaky_hash/bad_snake_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe "a bad one" do
4+
5+
subject(:bad_snake) do
6+
Class.new(Hashie::Mash) do
7+
include SnakyHash::Snake.new(key_type: :slartibartfarst)
8+
end
9+
end
10+
11+
it "raises an error" do
12+
block_is_expected.to raise_error(ArgumentError)
13+
end
14+
end

0 commit comments

Comments
 (0)