Skip to content

Commit 6e49469

Browse files
committed
✨ Add serializer option
1 parent 7c0f025 commit 6e49469

15 files changed

+493
-109
lines changed

lib/snaky_hash.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,30 @@
55
require "version_gem"
66

77
require_relative "snaky_hash/version"
8+
require_relative "snaky_hash/extensions"
9+
require_relative "snaky_hash/serializer"
810
require_relative "snaky_hash/snake"
911
require_relative "snaky_hash/string_keyed"
1012
require_relative "snaky_hash/symbol_keyed"
1113

1214
# This is the namespace for this gem
1315
module SnakyHash
16+
class Error < StandardError
17+
end
18+
19+
class << self
20+
def load_extensions
21+
@load_extensions ||= Extensions.new
22+
end
23+
24+
def dump_extensions
25+
@dump_extensions ||= Extensions.new
26+
end
27+
28+
def load_hash_extensions
29+
@load_hash_extensions ||= Extensions.new
30+
end
31+
end
1432
end
1533

1634
SnakyHash::Version.class_eval do

lib/snaky_hash/extensions.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
module SnakyHash
4+
class Extensions
5+
def initialize
6+
reset
7+
end
8+
9+
def reset
10+
@extensions = {}
11+
end
12+
13+
def add(name, &block)
14+
if has?(name)
15+
raise Error, "Extension already defined named '#{name}'"
16+
end
17+
18+
@extensions[name.to_sym] = block
19+
end
20+
21+
def has?(name)
22+
@extensions.key?(name.to_sym)
23+
end
24+
25+
def run(value)
26+
@extensions.each_value do |block|
27+
value = block.call(value)
28+
end
29+
value
30+
end
31+
end
32+
end

lib/snaky_hash/serializer.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
5+
module SnakyHash
6+
module Serializer
7+
def dump(obj)
8+
hash = dump_hash(obj)
9+
hash.to_json
10+
end
11+
12+
def load(raw_hash)
13+
hash = JSON.parse(presence(raw_hash) || "{}")
14+
hash = load_hash(hash)
15+
new(hash)
16+
end
17+
18+
private
19+
20+
def blank?(value)
21+
return true if value.nil?
22+
return true if value.is_a?(String) && value.empty?
23+
24+
false
25+
end
26+
27+
def presence(value)
28+
blank?(value) ? nil : value
29+
end
30+
31+
def dump_hash(hash)
32+
hash = hash.transform_values do |value|
33+
dump_value(value)
34+
end
35+
hash.reject { |_, v| blank?(v) }
36+
end
37+
38+
def dump_value(value)
39+
if blank?(value)
40+
return
41+
end
42+
43+
if value.is_a?(::Hash)
44+
return dump_hash(value)
45+
end
46+
47+
if value.is_a?(::Array)
48+
return value.map { |v| dump_value(v) }.compact
49+
end
50+
51+
SnakyHash.dump_extensions.run(value)
52+
end
53+
54+
def load_hash(hash)
55+
hash.transform_values do |value|
56+
load_value(value)
57+
end
58+
end
59+
60+
def load_value(value)
61+
if value.is_a?(::Hash)
62+
hash = SnakyHash.load_hash_extensions.run(value)
63+
64+
# If the result is still a hash, we'll return that here
65+
return load_hash(hash) if hash.is_a?(::Hash)
66+
67+
# If the result is not a hash, we'll just return whatever
68+
# was returned as a normal value.
69+
return load_value(hash)
70+
end
71+
72+
return value.map { |v| load_value(v) } if value.is_a?(Array)
73+
74+
SnakyHash.load_extensions.run(value)
75+
end
76+
end
77+
end

lib/snaky_hash/snake.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
# include Hashie::Extensions::Mash::SymbolizeKeys
66
module SnakyHash
77
class Snake < Module
8-
def initialize(key_type: :string)
8+
def initialize(key_type: :string, serializer: false)
99
super()
1010
@key_type = key_type
11+
@serializer = serializer
1112
end
1213

1314
def included(base)
1415
conversions_module = SnakyModulizer.to_mod(@key_type)
1516
base.include(conversions_module)
17+
base.extend(SnakyHash::Serializer) if @serializer
1618
end
1719

1820
module SnakyModulizer

lib/snaky_hash/string_keyed.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
module SnakyHash
2+
# serialized is being introduced as an always disabled option for backwards compatibility.
3+
# In snaky_hash v3 it will default to true.
4+
# If you want to start using it immediately, reopen this class and add the Serializer module:
5+
#
6+
# SnakyHash::StringKeyed.class_eval do
7+
# extend SnakyHash::Serializer
8+
# end
9+
#
210
class StringKeyed < Hashie::Mash
3-
include SnakyHash::Snake.new(key_type: :string)
11+
include SnakyHash::Snake.new(key_type: :string, serializer: false)
412
end
513
end

lib/snaky_hash/symbol_keyed.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
module SnakyHash
2+
# serialized is being introduced as an always disabled option for backwards compatibility.
3+
# In snaky_hash v3 it will default to true.
4+
# If you want to start using it immediately, reopen this class and add the Serializer module:
5+
#
6+
# SnakyHash::SymbolKeyed.class_eval do
7+
# extend SnakyHash::Serializer
8+
# end
9+
#
210
class SymbolKeyed < Hashie::Mash
3-
include SnakyHash::Snake.new(key_type: :symbol)
11+
include SnakyHash::Snake.new(key_type: :symbol, serializer: false)
412
end
513
end
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
RSpec.shared_examples_for 'a serialized hash' do
2+
describe ".dump" do
3+
after { SnakyHash.dump_extensions.reset }
4+
5+
it "returns a JSON string" do
6+
value = subject.dump({hello: "World"})
7+
expect(value).to eq '{"hello":"World"}'
8+
end
9+
10+
it "removes any nil values" do
11+
value = subject.dump({hello: "World", nilValue: nil})
12+
expect(value).to eq '{"hello":"World"}'
13+
end
14+
15+
it "removes empty strings" do
16+
value = subject.dump({hello: "World", nilValue: ""})
17+
expect(value).to eq '{"hello":"World"}'
18+
end
19+
20+
it "does not remove false" do
21+
value = subject.dump({hello: "World", falseValue: false})
22+
expect(value).to eq '{"hello":"World","falseValue":false}'
23+
end
24+
25+
it "removes any empty items from top-level arrays" do
26+
value = subject.dump({hello: "World", array: [nil, 1, 2, "", false]})
27+
expect(value).to eq '{"hello":"World","array":[1,2,false]}'
28+
end
29+
30+
it "passes through any extensions that have been added" do
31+
SnakyHash.dump_extensions.add(:test) { |value| value.upcase }
32+
value = subject.dump({hello: "WORLD"})
33+
expect(value).to eq '{"hello":"WORLD"}'
34+
end
35+
36+
it "works with nested hashes" do
37+
SnakyHash.dump_extensions.add(:test) { |value| value.is_a?(String) ? value.upcase : value }
38+
value = subject.dump({hello: "world", nested: {vegetable: "potato", more_nesting: {fruit: "banana"}}})
39+
expect(value).to eq '{"hello":"WORLD","nested":{"vegetable":"POTATO","more_nesting":{"fruit":"BANANA"}}}'
40+
end
41+
42+
it "works with nested hashes in arrays" do
43+
SnakyHash.dump_extensions.add(:test) { |value| value.is_a?(String) ? value.upcase : value }
44+
value = subject.dump({hello: "world", nested: {vegetables: [{name: "potato"}, {name: "cucumber"}], more_nesting: {fruits: [{name: "banana"}, {name: "apple"}]}}})
45+
expect(value).to eq '{"hello":"WORLD","nested":{"vegetables":[{"name":"POTATO"},{"name":"CUCUMBER"}],"more_nesting":{"fruits":[{"name":"BANANA"},{"name":"APPLE"}]}}}'
46+
end
47+
end
48+
49+
describe ".load" do
50+
after do
51+
SnakyHash.load_extensions.reset
52+
SnakyHash.load_hash_extensions.reset
53+
end
54+
55+
it "creates a Hashie::Mash from the given JSON" do
56+
hash = subject.load('{"hello":"world"}')
57+
expect(hash).to be_a Hashie::Mash
58+
expect(hash["hello"]).to eq "world"
59+
end
60+
61+
it "shuold create an empty Mash if the given value is nil" do
62+
hash = subject.load(nil)
63+
expect(hash).to be_a Hashie::Mash
64+
expect(hash).to be_empty
65+
end
66+
67+
it "creates an empty Mash if the JSON is an empty string" do
68+
hash = subject.load("")
69+
expect(hash).to be_a Hashie::Mash
70+
expect(hash).to be_empty
71+
end
72+
73+
it "passes through any extensions that have been added" do
74+
SnakyHash.load_extensions.add(:test) { |value| value.upcase }
75+
hash = subject.load('{"hello":"world"}')
76+
expect(hash).to be_a Hashie::Mash
77+
expect(hash["hello"]).to eq "WORLD"
78+
end
79+
80+
it "works with nested hashes" do
81+
SnakyHash.load_extensions.add(:test) { |value| value.is_a?(String) ? value.downcase : nil }
82+
hash = subject.load('{"hello":"WORLD","nested":{"vegetable":"POTATO","more_nesting":{"fruit":"BANANA"}}}')
83+
expect(hash).to be_a Hashie::Mash
84+
expect(hash).to eq({"hello" => "world", "nested" => {"vegetable" => "potato", "more_nesting" => {"fruit" => "banana"}}})
85+
end
86+
87+
it "works with nested hashes in arrays" do
88+
SnakyHash.load_extensions.add(:test) { |value| value.is_a?(String) ? value.downcase : nil }
89+
hash = subject.load('{"num":3,"hello":"WORLD","nested":{"vegetables":[{"name":"POTATO"},{"name":"CUCUMBER"}],"more_nesting":{"fruits":[{"name":"BANANA"},{"name":"APPLE"}]}}}')
90+
expect(hash).to be_a Hashie::Mash
91+
expect(hash).to eq({"num" => nil, "hello" => "world", "nested" => {"vegetables" => [{"name" => "potato"}, {"name" => "cucumber"}], "more_nesting" => {"fruits" => [{"name" => "banana"}, {"name" => "apple"}]}}})
92+
end
93+
94+
it "is unable to upcase keys, because instantiation of a SnakyHash downcases keys" do
95+
SnakyHash.load_hash_extensions.add(:test) { |value|
96+
if value.is_a?(Hash)
97+
value.transform_keys(&:upcase)
98+
else
99+
value
100+
end
101+
}
102+
hash = subject.load('{"some_hash":{"name":"Michael"}}')
103+
expect(hash).to be_a Hashie::Mash
104+
expect(hash).to eq({"some_hash" => {"name" => "Michael"}})
105+
end
106+
107+
it "passes through hashes through their own extensions" do
108+
SnakyHash.load_hash_extensions.add(:test) { |value|
109+
if value.is_a?(Hash)
110+
value.transform_keys(&:next)
111+
else
112+
value
113+
end
114+
}
115+
hash = subject.load('{"some_hash":{"name":"Michael"}}')
116+
expect(hash).to be_a Hashie::Mash
117+
expect(hash).to eq({"some_hash" => {"namf" => "Michael"}})
118+
end
119+
120+
it "passes hashses through their own extension and return non-hash values properly" do
121+
SnakyHash.load_hash_extensions.add(:test) { |value|
122+
if value.is_a?(Hash)
123+
value.key?("name") ? value["name"] : value
124+
else
125+
value
126+
end
127+
}
128+
hash = subject.load('{"some_hash":{"name":"Michael"}}')
129+
expect(hash).to be_a Hashie::Mash
130+
expect(hash).to eq({"some_hash" => "Michael"})
131+
end
132+
end
133+
end

0 commit comments

Comments
 (0)