Skip to content

Commit

Permalink
Add an extension for persisting Hashes to files
Browse files Browse the repository at this point in the history
Building on @maxlinc's start, this implements a Persistable extension
that you can mix into any Hash to gain access to file persistence.
Currently, there are two adapters: JSON and YAML.

The persistence framework is easily expandable via adapters that can be
registered and hotloaded into a Persistable module. The module defaults
to a JSON adapter with a `#persist` method when mixed into a derived
Hash class, but can be customized as follows:

```ruby
class YamlPersistedHash < Hash
  include Hashie::Extensions::Persistence.new(
    adapter: :yaml,
    persist_method: :save
  )
end

test = YamlPersistedHash['test' => 'value']  #=> {'test' => 'value'}
test.save('filename.yaml')
```

To create a new adapter, you only need to register a class, module, or
object that responds to an `#adapter` method. The `#adapter` method
needs to respond with an object with a `#write(target, data)` method
that knows how to write the data for persistence. For two examples, see
the `Hashie::Extensions::Persistable::Json` and
`Hashie::Extensions::Persistable::Yaml` modules.

Closes hashie#262 by finishing and expanding the implementation.
  • Loading branch information
michaelherold committed Feb 1, 2016
1 parent b458e72 commit 6f34588
Show file tree
Hide file tree
Showing 7 changed files with 446 additions and 0 deletions.
2 changes: 2 additions & 0 deletions hashie.gemspec
Expand Up @@ -16,6 +16,8 @@ Gem::Specification.new do |gem|
gem.files += Dir['spec/**/*.rb']
gem.test_files = Dir['spec/**/*.rb']

gem.add_dependency 'module_builder', '~> 0.1'

gem.add_development_dependency 'rake'
gem.add_development_dependency 'rspec', '~> 3.0'
gem.add_development_dependency 'rspec-pending_for', '~> 0.1'
Expand Down
1 change: 1 addition & 0 deletions lib/hashie.rb
Expand Up @@ -18,6 +18,7 @@ module Extensions
autoload :MethodQuery, 'hashie/extensions/method_access'
autoload :MethodReader, 'hashie/extensions/method_access'
autoload :MethodWriter, 'hashie/extensions/method_access'
autoload :Persistable, 'hashie/extensions/persistable'
autoload :StringifyKeys, 'hashie/extensions/stringify_keys'
autoload :SymbolizeKeys, 'hashie/extensions/symbolize_keys'
autoload :DeepFetch, 'hashie/extensions/deep_fetch'
Expand Down
121 changes: 121 additions & 0 deletions lib/hashie/extensions/persistable.rb
@@ -0,0 +1,121 @@
require 'hashie/extensions/persistable/builder'
require 'module_builder/buildable'

module Hashie
module Extensions
# Give persistance capabilities to a Hash via different file adapters.
#
# @api public
module Persistable
include ModuleBuilder::Buildable

# Raised when there is an error with the configuration for a Persistable
# Hash. This is a generic error and shouldn't be raised directly, but
# should be subclassed appropriately.
PersistableError = Class.new(StandardError)

# Raised when there is no adapter set for the type of data to write.
UnknownAdapter = Class.new(PersistableError)

# Raised when there is no location set for where to persist the Hash.
UnknownLocation = Class.new(PersistableError)

# An internal adapter identifier-to-adapter map used for setup.
#
# @return [Hash<Symbol=>Module>]
#
# @api private
def self.adapters
@adapters ||= {}
end

# Load a source into a new copy of the Hash class.
#
# @param [#read, #to_io, #to_str] source The source to load from.
# @param [Hash] options
# @option [#load] adapter An adapter that responds to #load.
#
# @return [Hash]
#
# @api public
def self.load(source, options = {})
adapter = options.fetch(:adapter) { fail(UnknownAdapter, 'You did not specify an adapter for this Persistable Hash.'.freeze) }

adapter.load(source)
end

# Persist a Hash to a file in a specified format.
#
# @param [Hash] hash The hash to persist.
# @param [Hash] options
# @option options [#write] adapter An adapter that responds to #write.
# @option options [#write] target The target to persist to.
#
# @return [#read]
#
# @api public
def self.persist(hash, options = {})
adapter = options.fetch(:adapter) { fail(UnknownAdapter, 'You did not specify an adapter for this Persistable Hash.'.freeze) }
target = options.fetch(:target) { fail(UnknownLocation, 'You did not specify where you want to persist this Persistable Hash.'.freeze) }

adapter.write(target, hash)
end

# Register adapter namespace under a specified identifier.
#
# @param [Symbol] identifier
# @param [Module] adapter
#
# @return [self]
#
# @api public
def self.register_adapter(identifier, adapter)
adapters[identifier] = adapter
self
end

# Add the #persist method to a Hash.
#
# @api public
module Persistence
# Persist the Hash to the given store.
#
# @note When the Hash has previously been persisted, this can be
# called without a parameter to persist to the last location.
#
# @param [String, #write] store The path name or actual target to write to.
#
# @return [#read]
#
# @raise [ArgumentError] if the Hash was not previously persisted and
# file is nil
def persist(store = nil)
self.persistable_store = store unless store.nil?

if persistable_store.nil?
fail(UnknownLocation, 'You did not specify where you want to persist this Persistable Hash.'.freeze)
else
Persistable.persist(self, adapter: adapter, target: persistable_store)
persistable_store
end
end

private

attr_reader :persistable_store

# @api private
def persistable_store=(store)
if store.is_a? String
@persistable_store = Pathname.new(store)
else
@persistable_store = store
end
end
end
end
end
end

require 'hashie/extensions/persistable/json'
require 'hashie/extensions/persistable/yaml'
40 changes: 40 additions & 0 deletions lib/hashie/extensions/persistable/builder.rb
@@ -0,0 +1,40 @@
require 'module_builder/builder'

module Hashie
module Extensions
module Persistable
# Class to build a Persistable module with its own configuration.
#
# This allows for invidiual Persistable modules to be included in
# classes and not impact the global Persistable configuration.
#
# @api private
class Builder < ModuleBuilder::Builder
def defaults
{ adapter: :json, persist_method: :persist }
end

def hooks
[:add_adapter, :override_persist_method]
end

def inclusions
[Hashie::Extensions::Persistable::Persistence]
end

private

def add_adapter
@module.__send__(:include, Hashie::Extensions::Persistable.adapters[@adapter])
end

def override_persist_method
return if @persist_method == :persist

@module.__send__(:alias_method, @persist_method, :persist)
@module.__send__(:undef_method, :persist)
end
end
end
end
end
56 changes: 56 additions & 0 deletions lib/hashie/extensions/persistable/json.rb
@@ -0,0 +1,56 @@
require 'json'

module Hashie
module Extensions
module Persistable
# Persistable adapter that serializes a Hash to a JSON file.
#
# @example
# class PersistableHash < Hash
# include Hashie::Extensions::Persistable.new(adapter: :json)
# end
#
# data = PersistableHash[test: 'value']
# data.persist('data.json')
module Json
# Accessor method for an instance of the JSON Adapter.
#
# @api private
def adapter
Adapter.new
end

# Class that handles serializing a Hash to a JSON file.
#
# @api public
class Adapter
# Load a hash from a JSON source.
#
# @param [#read, #to_io, #to_str] source The source to load from.
#
# @return [Hash]
#
# @api public
def load(source)
::JSON.load(source)
end

# Write a hash to the JSON file.
#
# @param [#write] target
# @param [Hash] hash
#
# @return [#read]
#
# @api public
def write(target, hash)
target.write(::JSON.dump(hash))
target
end
end
end
end
end
end

Hashie::Extensions::Persistable.register_adapter(:json, Hashie::Extensions::Persistable::Json)
56 changes: 56 additions & 0 deletions lib/hashie/extensions/persistable/yaml.rb
@@ -0,0 +1,56 @@
require 'yaml'

module Hashie
module Extensions
module Persistable
# Persistable adapter that serializes a Hash to a YAML file.
#
# @example
# class PersistableHash < Hash
# include Hashie::Extensions::Persistable.new(adapter: :yaml)
# end
#
# data = PersistableHash[test: 'value']
# data.persist('data.yml')
module Yaml
# Accessor method for an instance of the YAML Adapter.
#
# @api private
def adapter
Adapter.new
end

# Class that handles serializing a Hash to a YAML file.
#
# @api public
class Adapter
# Load a hash from a YAML source.
#
# @param [#read, #to_io, #to_str] source The source to load from.
#
# @return [Hash]
#
# @api public
def load(source)
::YAML.load(source)
end

# Write a hash to the YAML file.
#
# @param [#write] target
# @param [Hash] hash
#
# @return [#read]
#
# @api public
def write(target, hash)
target.write(::YAML.dump(hash.to_h))
target
end
end
end
end
end
end

Hashie::Extensions::Persistable.register_adapter(:yaml, Hashie::Extensions::Persistable::Yaml)

0 comments on commit 6f34588

Please sign in to comment.