lib/snaky_hash/serializer.rb
# frozen_string_literal: true require "json" module SnakyHash # Provides JSON serialization and deserialization capabilities with extensible value transformation # # @example Basic usage # class MyHash < Hashie::Mash # extend SnakyHash::Serializer # end # hash = MyHash.load('{"key": "value"}') # hash.dump #=> '{"key":"value"}' # module Serializer class << self # Extends the base class with serialization capabilities # # @param base [Class] the class being extended # @return [void] def extended(base) extended_module = Modulizer.to_extended_mod base.extend(extended_module) base.include(ConvenienceInstanceMethods) # :nocov: # This will be run in CI on Ruby 2.3, but we only collect coverage from current Ruby unless base.instance_methods.include?(:transform_values) base.include(BackportedInstanceMethods) end # :nocov: end end # Serializes a hash object to JSON # # @param obj [Hash] the hash to serialize # @return [String] JSON string representation of the hash def dump(obj) hash = dump_hash(obj) hash.to_json end # Deserializes a JSON string into a hash object # # @param raw_hash [String, nil] JSON string to deserialize # @return [Hash] deserialized hash object def load(raw_hash) hash = JSON.parse(presence(raw_hash) || "{}") load_hash(new(hash)) end # Internal module for generating extension methods module Modulizer class << self # Creates a new module with extension management methods # # @return [Module] a module containing extension management methods def to_extended_mod Module.new do define_method :load_value_extensions do @load_value_extensions ||= Extensions.new end define_method :load_extensions do load_value_extensions end define_method :dump_value_extensions do @dump_value_extensions ||= Extensions.new end define_method :dump_extensions do dump_value_extensions end define_method :load_hash_extensions do @load_hash_extensions ||= Extensions.new end define_method :dump_hash_extensions do @dump_hash_extensions ||= Extensions.new end end end end end # Provides backported methods for older Ruby versions module BackportedInstanceMethods # :nocov: # Transforms values of a hash using the given block # # @yield [Object] block to transform each value # @return [Hash] new hash with transformed values # @return [Enumerator] if no block given # @note This will be run in CI on Ruby 2.3, but we only collect coverage from current Ruby # Rails <= 5.2 had a transform_values method, which was added to Ruby in version 2.4. # This method is a backport of that original Rails method for Ruby 2.2 and 2.3. def transform_values(&block) return enum_for(:transform_values) { size } unless block_given? return {} if empty? result = self.class.new each do |key, value| result[key] = yield(value) end result end # :nocov: end # Provides convenient instance methods for serialization # # @example Using convenience methods # hash = MyHash.new(key: 'value') # json = hash.dump #=> '{"key":"value"}' module ConvenienceInstanceMethods # Serializes the current hash instance to JSON # # @return [String] JSON string representation of the hash def dump self.class.dump(self) end end private # Checks if a value is blank (nil or empty string) # # @param value [Object] value to check # @return [Boolean] true if value is blank def blank?(value) return true if value.nil? return true if value.is_a?(String) && value.empty? false end # Returns nil if value is blank, otherwise returns the value # # @param value [Object] value to check # @return [Object, nil] the value or nil if blank def presence(value) blank?(value) ? nil : value end # Processes a hash for dumping, transforming its keys and/or values # # @param hash [Hash] hash to process # @return [Hash] processed hash with transformed values def dump_hash(hash) dump_hash_extensions.run(self[hash]).transform_values do |value| dump_value(value) end end # Processes a single value for dumping # # @param value [Object] value to process # @return [Object, nil] processed value def dump_value(value) if blank?(value) return value end if value.is_a?(::Hash) return dump_hash(value) end if value.is_a?(::Array) return value.map { |v| dump_value(v) }.compact end dump_extensions.run(value) end # Processes a hash for loading, transforming its keys and/or values # # @param hash [Hash] hash to process # @return [Hash] processed hash with transformed values def load_hash(hash) ran = load_hash_extensions.run(self[hash]) return load_value(ran) unless ran.is_a?(::Hash) res = self[ran].transform_values do |value| load_value(value) end # TODO: Drop this hack when dropping support for Ruby 2.6 if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.7") res else # :nocov: # In Ruby <= 2.6 Hash#transform_values returned a new vanilla Hash, # rather than a hash of the class being transformed. self[res] # :nocov: end end # Processes a single value for loading # # @param value [Object] value to process # @return [Object, nil] processed value def load_value(value) if blank?(value) return value end if value.is_a?(::Hash) return load_hash(value) end if value.is_a?(::Array) return value.map { |v| load_value(v) }.compact end load_extensions.run(value) end end end