snaky_hash 2.0.1 → 2.0.2
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +19 -1
- data/CODE_OF_CONDUCT.md +74 -25
- data/CONTRIBUTING.md +131 -12
- data/LICENSE.txt +1 -1
- data/README.md +523 -17
- data/SECURITY.md +2 -1
- data/lib/snaky_hash/extensions.rb +57 -0
- data/lib/snaky_hash/serializer.rb +177 -0
- data/lib/snaky_hash/snake.rb +92 -47
- data/lib/snaky_hash/string_keyed.rb +9 -1
- data/lib/snaky_hash/symbol_keyed.rb +9 -1
- data/lib/snaky_hash/version.rb +7 -1
- data/lib/snaky_hash.rb +25 -1
- data.tar.gz.sig +0 -0
- metadata +124 -54
- metadata.gz.sig +0 -0
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SnakyHash
|
4
|
+
# Manages extensions that can modify values in a chain of transformations
|
5
|
+
#
|
6
|
+
# @example Adding and running an extension
|
7
|
+
# extensions = Extensions.new
|
8
|
+
# extensions.add(:upcase) { |value| value.to_s.upcase }
|
9
|
+
# extensions.run("hello") #=> "HELLO"
|
10
|
+
#
|
11
|
+
class Extensions
|
12
|
+
# Initializes a new Extensions instance with an empty extensions registry
|
13
|
+
def initialize
|
14
|
+
reset
|
15
|
+
end
|
16
|
+
|
17
|
+
# Reset the registry of extensions to an empty state
|
18
|
+
#
|
19
|
+
# @return [Hash] an empty hash of extensions
|
20
|
+
def reset
|
21
|
+
@extensions = {}
|
22
|
+
end
|
23
|
+
|
24
|
+
# Adds a new extension with the given name
|
25
|
+
#
|
26
|
+
# @param name [String, Symbol] the name of the extension
|
27
|
+
# @yield [value] block that will be called with a value to transform
|
28
|
+
# @raise [SnakyHash::Error] if an extension with the given name already exists
|
29
|
+
# @return [Proc] the added extension block
|
30
|
+
def add(name, &block)
|
31
|
+
if has?(name)
|
32
|
+
raise Error, "Extension already defined named '#{name}'"
|
33
|
+
end
|
34
|
+
|
35
|
+
@extensions[name.to_sym] = block
|
36
|
+
end
|
37
|
+
|
38
|
+
# Checks if an extension with the given name exists
|
39
|
+
#
|
40
|
+
# @param name [String, Symbol] the name of the extension to check
|
41
|
+
# @return [Boolean] true if the extension exists, false otherwise
|
42
|
+
def has?(name)
|
43
|
+
@extensions.key?(name.to_sym)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Runs all registered extensions in sequence on the given value
|
47
|
+
#
|
48
|
+
# @param value [Object] the value to transform through all extensions
|
49
|
+
# @return [Object] the final transformed value after running all extensions
|
50
|
+
def run(value)
|
51
|
+
@extensions.each_value do |block|
|
52
|
+
value = block.call(value)
|
53
|
+
end
|
54
|
+
value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module SnakyHash
|
6
|
+
# Provides JSON serialization and deserialization capabilities with extensible value transformation
|
7
|
+
#
|
8
|
+
# @example Basic usage
|
9
|
+
# class MyHash < Hashie::Mash
|
10
|
+
# extend SnakyHash::Serializer
|
11
|
+
# end
|
12
|
+
# hash = MyHash.load('{"key": "value"}')
|
13
|
+
# hash.dump #=> '{"key":"value"}'
|
14
|
+
#
|
15
|
+
module Serializer
|
16
|
+
class << self
|
17
|
+
# Extends the base class with serialization capabilities
|
18
|
+
#
|
19
|
+
# @param base [Class] the class being extended
|
20
|
+
# @return [void]
|
21
|
+
def extended(base)
|
22
|
+
extended_module = Modulizer.to_extended_mod
|
23
|
+
base.extend(extended_module)
|
24
|
+
# :nocov:
|
25
|
+
# This will be run in CI on Ruby 2.3, but we only collect coverage from current Ruby
|
26
|
+
unless base.instance_methods.include?(:transform_values)
|
27
|
+
base.include(BackportedInstanceMethods)
|
28
|
+
end
|
29
|
+
# :nocov:
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Serializes a hash object to JSON
|
34
|
+
#
|
35
|
+
# @param obj [Hash] the hash to serialize
|
36
|
+
# @return [String] JSON string representation of the hash
|
37
|
+
def dump(obj)
|
38
|
+
hash = dump_hash(obj)
|
39
|
+
hash.to_json
|
40
|
+
end
|
41
|
+
|
42
|
+
# Deserializes a JSON string into a hash object
|
43
|
+
#
|
44
|
+
# @param raw_hash [String, nil] JSON string to deserialize
|
45
|
+
# @return [Hash] deserialized hash object
|
46
|
+
def load(raw_hash)
|
47
|
+
hash = JSON.parse(presence(raw_hash) || "{}")
|
48
|
+
hash = load_value(new(hash))
|
49
|
+
new(hash)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Internal module for generating extension methods
|
53
|
+
module Modulizer
|
54
|
+
class << self
|
55
|
+
# Creates a new module with extension management methods
|
56
|
+
#
|
57
|
+
# @return [Module] a module containing extension management methods
|
58
|
+
def to_extended_mod
|
59
|
+
Module.new do
|
60
|
+
define_method :load_extensions do
|
61
|
+
@load_extensions ||= Extensions.new
|
62
|
+
end
|
63
|
+
|
64
|
+
define_method :dump_extensions do
|
65
|
+
@dump_extensions ||= Extensions.new
|
66
|
+
end
|
67
|
+
|
68
|
+
define_method :load_hash_extensions do
|
69
|
+
@load_hash_extensions ||= Extensions.new
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Provides backported methods for older Ruby versions
|
77
|
+
module BackportedInstanceMethods
|
78
|
+
# :nocov:
|
79
|
+
# Transforms values of a hash using the given block
|
80
|
+
#
|
81
|
+
# @yield [Object] block to transform each value
|
82
|
+
# @return [Hash] new hash with transformed values
|
83
|
+
# @return [Enumerator] if no block given
|
84
|
+
# @note This will be run in CI on Ruby 2.3, but we only collect coverage from current Ruby
|
85
|
+
# Rails <= 5.2 had a transform_values method, which was added to Ruby in version 2.4.
|
86
|
+
# This method is a backport of that original Rails method for Ruby 2.2 and 2.3.
|
87
|
+
def transform_values(&block)
|
88
|
+
return enum_for(:transform_values) { size } unless block_given?
|
89
|
+
return {} if empty?
|
90
|
+
result = self.class.new
|
91
|
+
each do |key, value|
|
92
|
+
result[key] = yield(value)
|
93
|
+
end
|
94
|
+
result
|
95
|
+
end
|
96
|
+
# :nocov:
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
# Checks if a value is blank (nil or empty string)
|
102
|
+
#
|
103
|
+
# @param value [Object] value to check
|
104
|
+
# @return [Boolean] true if value is blank
|
105
|
+
def blank?(value)
|
106
|
+
return true if value.nil?
|
107
|
+
return true if value.is_a?(String) && value.empty?
|
108
|
+
|
109
|
+
false
|
110
|
+
end
|
111
|
+
|
112
|
+
# Returns nil if value is blank, otherwise returns the value
|
113
|
+
#
|
114
|
+
# @param value [Object] value to check
|
115
|
+
# @return [Object, nil] the value or nil if blank
|
116
|
+
def presence(value)
|
117
|
+
blank?(value) ? nil : value
|
118
|
+
end
|
119
|
+
|
120
|
+
# Processes a hash for dumping, transforming its values
|
121
|
+
#
|
122
|
+
# @param hash [Hash] hash to process
|
123
|
+
# @return [Hash] processed hash with transformed values
|
124
|
+
def dump_hash(hash)
|
125
|
+
hash = self[hash].transform_values do |value|
|
126
|
+
dump_value(value)
|
127
|
+
end
|
128
|
+
hash.reject { |_, v| blank?(v) }
|
129
|
+
end
|
130
|
+
|
131
|
+
# Processes a single value for dumping
|
132
|
+
#
|
133
|
+
# @param value [Object] value to process
|
134
|
+
# @return [Object, nil] processed value
|
135
|
+
def dump_value(value)
|
136
|
+
if blank?(value)
|
137
|
+
return
|
138
|
+
end
|
139
|
+
|
140
|
+
if value.is_a?(::Hash)
|
141
|
+
return dump_hash(value)
|
142
|
+
end
|
143
|
+
|
144
|
+
if value.is_a?(::Array)
|
145
|
+
return value.map { |v| dump_value(v) }.compact
|
146
|
+
end
|
147
|
+
|
148
|
+
dump_extensions.run(value)
|
149
|
+
end
|
150
|
+
|
151
|
+
# Processes a hash for loading, transforming its values
|
152
|
+
#
|
153
|
+
# @param hash [Hash] hash to process
|
154
|
+
# @return [Hash] processed hash with transformed values
|
155
|
+
def load_hash(hash)
|
156
|
+
hash.transform_values do |value|
|
157
|
+
load_value(value)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Processes a single value for loading
|
162
|
+
#
|
163
|
+
# @param value [Object] value to process
|
164
|
+
# @return [Object] processed value
|
165
|
+
def load_value(value)
|
166
|
+
if value.is_a?(::Hash)
|
167
|
+
hash = load_hash_extensions.run(new(value))
|
168
|
+
return load_hash(new(hash)) if hash.is_a?(::Hash)
|
169
|
+
return load_value(hash)
|
170
|
+
end
|
171
|
+
|
172
|
+
return value.map { |v| load_value(v) } if value.is_a?(Array)
|
173
|
+
|
174
|
+
load_extensions.run(value)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
data/lib/snaky_hash/snake.rb
CHANGED
@@ -1,69 +1,114 @@
|
|
1
1
|
# This is a module-class hybrid.
|
2
2
|
#
|
3
|
+
# A flexible key conversion system that supports both String and Symbol keys,
|
4
|
+
# with optional serialization capabilities.
|
5
|
+
#
|
6
|
+
# @example Basic usage with string keys
|
7
|
+
# class MyHash < Hashie::Mash
|
8
|
+
# include SnakyHash::Snake.new(key_type: :string)
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# @example Usage with symbol keys and serialization
|
12
|
+
# class MySerializableHash < Hashie::Mash
|
13
|
+
# include SnakyHash::Snake.new(key_type: :symbol, serializer: true)
|
14
|
+
# end
|
15
|
+
#
|
3
16
|
# Hashie's standard SymbolizeKeys is similar to the functionality we want.
|
4
17
|
# ... but not quite. We need to support both String (for oauth2) and Symbol keys (for oauth).
|
5
|
-
#
|
18
|
+
# see: Hashie::Extensions::Mash::SymbolizeKeys
|
19
|
+
#
|
6
20
|
module SnakyHash
|
21
|
+
# Creates a module that provides key conversion functionality when included
|
22
|
+
#
|
23
|
+
# @note Unlike Hashie::Mash, this implementation allows for both String and Symbol key types
|
7
24
|
class Snake < Module
|
8
|
-
|
25
|
+
# Initialize a new Snake module
|
26
|
+
#
|
27
|
+
# @param key_type [Symbol] the type to convert keys to (:string or :symbol)
|
28
|
+
# @param serializer [Boolean] whether to include serialization capabilities
|
29
|
+
# @raise [ArgumentError] if key_type is not :string or :symbol
|
30
|
+
def initialize(key_type: :string, serializer: false)
|
9
31
|
super()
|
10
32
|
@key_type = key_type
|
33
|
+
@serializer = serializer
|
11
34
|
end
|
12
35
|
|
36
|
+
# Includes appropriate conversion methods into the base class
|
37
|
+
#
|
38
|
+
# @param base [Class] the class including this module
|
39
|
+
# @return [void]
|
13
40
|
def included(base)
|
14
41
|
conversions_module = SnakyModulizer.to_mod(@key_type)
|
15
42
|
base.include(conversions_module)
|
43
|
+
if @serializer
|
44
|
+
base.extend(SnakyHash::Serializer)
|
45
|
+
end
|
16
46
|
end
|
17
47
|
|
48
|
+
# Internal module factory for creating key conversion functionality
|
18
49
|
module SnakyModulizer
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
+
class << self
|
51
|
+
# Creates a new module with key conversion methods based on the specified key type
|
52
|
+
#
|
53
|
+
# @param key_type [Symbol] the type to convert keys to (:string or :symbol)
|
54
|
+
# @return [Module] a new module with conversion methods
|
55
|
+
# @raise [ArgumentError] if key_type is not supported
|
56
|
+
def to_mod(key_type)
|
57
|
+
Module.new do
|
58
|
+
case key_type
|
59
|
+
when :string then
|
60
|
+
# Converts a key to a string if possible, after underscoring
|
61
|
+
#
|
62
|
+
# @param key [Object] the key to convert
|
63
|
+
# @return [String, Object] the converted key or original if not convertible
|
64
|
+
define_method(:convert_key) { |key| key.respond_to?(:to_sym) ? underscore_string(key.to_s) : key }
|
65
|
+
when :symbol then
|
66
|
+
# Converts a key to a symbol if possible, after underscoring
|
67
|
+
#
|
68
|
+
# @param key [Object] the key to convert
|
69
|
+
# @return [Symbol, Object] the converted key or original if not convertible
|
70
|
+
define_method(:convert_key) { |key| key.respond_to?(:to_sym) ? underscore_string(key.to_s).to_sym : key }
|
50
71
|
else
|
51
|
-
|
72
|
+
raise ArgumentError, "SnakyHash: Unhandled key_type: #{key_type}"
|
52
73
|
end
|
53
|
-
end
|
54
74
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
75
|
+
# Converts hash values to the appropriate type when assigning
|
76
|
+
#
|
77
|
+
# @param val [Object] the value to convert
|
78
|
+
# @param duping [Boolean] whether the value is being duplicated
|
79
|
+
# @return [Object] the converted value
|
80
|
+
define_method :convert_value do |val, duping = false| #:nodoc:
|
81
|
+
case val
|
82
|
+
when self.class
|
83
|
+
val.dup
|
84
|
+
when ::Hash
|
85
|
+
val = val.dup if duping
|
86
|
+
self.class.new(val)
|
87
|
+
when ::Array
|
88
|
+
val.collect { |e| convert_value(e) }
|
89
|
+
else
|
90
|
+
val
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Converts a string to underscore case
|
95
|
+
#
|
96
|
+
# @param str [String, #to_s] the string to convert
|
97
|
+
# @return [String] the underscored string
|
98
|
+
# @example
|
99
|
+
# underscore_string("CamelCase") #=> "camel_case"
|
100
|
+
# underscore_string("API::V1") #=> "api/v1"
|
101
|
+
# @note This is the same as ActiveSupport's String#underscore
|
102
|
+
define_method :underscore_string do |str|
|
103
|
+
str.to_s.strip.
|
104
|
+
tr(" ", "_").
|
105
|
+
gsub("::", "/").
|
106
|
+
gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
|
107
|
+
gsub(/([a-z\d])([A-Z])/, '\1_\2').
|
108
|
+
tr("-", "_").
|
109
|
+
squeeze("_").
|
110
|
+
downcase
|
111
|
+
end
|
67
112
|
end
|
68
113
|
end
|
69
114
|
end
|
@@ -1,5 +1,13 @@
|
|
1
1
|
module SnakyHash
|
2
|
+
# serializer 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
|
+
#
|
2
10
|
class StringKeyed < Hashie::Mash
|
3
|
-
include SnakyHash::Snake.new(key_type: :string)
|
11
|
+
include SnakyHash::Snake.new(key_type: :string, serializer: false)
|
4
12
|
end
|
5
13
|
end
|
@@ -1,5 +1,13 @@
|
|
1
1
|
module SnakyHash
|
2
|
+
# serializer 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
|
+
#
|
2
10
|
class SymbolKeyed < Hashie::Mash
|
3
|
-
include SnakyHash::Snake.new(key_type: :symbol)
|
11
|
+
include SnakyHash::Snake.new(key_type: :symbol, serializer: false)
|
4
12
|
end
|
5
13
|
end
|
data/lib/snaky_hash/version.rb
CHANGED
@@ -1,7 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module SnakyHash
|
4
|
+
# Defines the version information for SnakyHash
|
5
|
+
#
|
6
|
+
# @api public
|
4
7
|
module Version
|
5
|
-
|
8
|
+
# Current version of SnakyHash
|
9
|
+
#
|
10
|
+
# @return [String] the current version in semantic versioning format
|
11
|
+
VERSION = "2.0.2"
|
6
12
|
end
|
7
13
|
end
|
data/lib/snaky_hash.rb
CHANGED
@@ -5,14 +5,38 @@ require "hashie"
|
|
5
5
|
require "version_gem"
|
6
6
|
|
7
7
|
require_relative "snaky_hash/version"
|
8
|
+
require_relative "snaky_hash/extensions"
|
9
|
+
require_relative "snaky_hash/serializer"
|
8
10
|
require_relative "snaky_hash/snake"
|
9
11
|
require_relative "snaky_hash/string_keyed"
|
10
12
|
require_relative "snaky_hash/symbol_keyed"
|
11
13
|
|
12
|
-
#
|
14
|
+
# SnakyHash provides hash-like objects with automatic key conversion capabilities
|
15
|
+
#
|
16
|
+
# @example Using StringKeyed hash
|
17
|
+
# hash = SnakyHash::StringKeyed.new
|
18
|
+
# hash["camelCase"] = "value"
|
19
|
+
# hash["camel_case"] # => "value"
|
20
|
+
#
|
21
|
+
# @example Using SymbolKeyed hash
|
22
|
+
# hash = SnakyHash::SymbolKeyed.new
|
23
|
+
# hash["camelCase"] = "value"
|
24
|
+
# hash[:camel_case] # => "value"
|
25
|
+
#
|
26
|
+
# @see SnakyHash::StringKeyed
|
27
|
+
# @see SnakyHash::SymbolKeyed
|
28
|
+
# @see SnakyHash::Snake
|
13
29
|
module SnakyHash
|
30
|
+
# Base error class for all SnakyHash errors
|
31
|
+
#
|
32
|
+
# @api public
|
33
|
+
class Error < StandardError
|
34
|
+
end
|
14
35
|
end
|
15
36
|
|
37
|
+
# Enable version introspection via VersionGem
|
38
|
+
#
|
39
|
+
# @api private
|
16
40
|
SnakyHash::Version.class_eval do
|
17
41
|
extend VersionGem::Basic
|
18
42
|
end
|
data.tar.gz.sig
CHANGED
Binary file
|