Traitorous 
This is a simple trait based system that emphasizes reading in and out data structures, using an plugin Converter system. Each trait defined has a name and a Converter obj that responds to :export, and :import.
This process came out of a need to have a flexible config system that could be read from files easily (yaml, json, f*$k xml), populate a nested set of objects, and able to export the while thing ready to be encoded and saved back to disk.
The converters can be used to help (de)serialize, set default values, do validations and translations. The converter's job is to be able to import a value, instantiating into classes and assigning values.
I took a lot of inspiration from virtus.
Installation
Add this line to your application's Gemfile:
gem 'traitorous'
And then execute:
$ bundle
Or install it yourself as:
$ gem install traitorous
Usage
# see spec/
require 'traitorous'
class Ruin
include Traitorous
include Traitorous::Equality
trait :name
trait :danger
end
r = Ruin.new(name: 'Skull Mountain', danger: 'The Devil of')
#
puts r.name
# Skull Mountain
puts r.danger
# The Devil of
puts r.export
# {"name"=>"Skull Mountain", "danger"=>"The Devil of"}
puts Ruin.new(r.export) == r
# true
class Area
include Traitorous
include Traitorous::Equality
trait :name
trait :size, Converter::DefaultValueStatic.new('sub-continent')
trait :ruins, Converter::UniformArray.new(Ruin)
end
area = Area.new(
name: 'Western Marches',
ruins: [{name: 'Skull Mountain', danger: 'The Devil of'},
{name: 'Dire Swamp', danger: 'The Devil of'}
]
)
puts area.size
# 'sub-continent'
puts area.ruins.length
# 2
puts area.export
# {:name=>"Western Slope", :size=> "sub-continent", :ruins=>[{:name=>"Dire Swamp", :danger=>"The Creature of"}, {:name=>"Skull Mountain", :danger=>"The Devil of"}]}
puts Area.new(area.export) == area
# true
Converters
The purpose of the converters are to facilitate the importation of simple JSON or YAML data and import that data into an arbitrarily nested tree of objects. And then to take those object and be able to export that data in a simple form ready to save.
This system should be flexible enough to account for an large variety of data structures that can be read in and out of storage easily and in 1 tree.
The 5 that are in the system should cover a lot of ground, anything trickier
should just be implemented as it's own Converter, ensuring that it responds to
#do_import(data) and #do_export(data).
Most of the converters wil have a pair of optional arguments: import_method (defaults to :new) and export_method (defaults to :export). Export is simple, it's the method called on the object, objects created using Traitorous automatically respond_to export. set export_method to :to_s in order to convert your Pathname object into a string. Set import_method to :parse, to create your object with URI.parse. This should provide a tremendous amount of versatility.
# Example of using the defaults for IMport_method and export_method
# import_method defaults to :new
# export_method defaults to :export
batman_data = {name: 'Batman', secret_identity: 'Bruce Wayne'}
converter = Model.new(Superhero)
superhero = converter.do_import(batman_data)
# does this
Superhero.send(import_method, data)
# and is the same result as
Superhero.new(batman_data)
# exports by
converter.do_export(superhero)
# does this
superhero.send(export_method)
# and is the same result as
superhero.export
# Example of using a custom import_method and export_method
batman_data_uri = '/batman/current'
converter = Model.new(Superhero, import_method: :parse, export_method: :to_s)
superhero = converter.do_import(batman_data)
# does this
uri = URI.send(import_method, batman_data)
# and is the same result as
URI.parse(batman_data)
# exports by
converter.do_export(uri)
# does this
uri.send(export_method)
# and is the same result as
uri.to_s
export
My current design kind of places a lot of the responsibilities for the export sequence of the objects themselves. And I'm both satisied with this design and not at all sure that it's the right design. The importers gets the combination of the klass and the import_method which makes for a near perfect amount of coverage, they cover everything from URI.parse to File.read to Animal.new to YAML.load_file. But the exporters are expected to get with just the export_method setting to be called on the object itself.
THe concepts of the converters could easily be isolated into a single type of converter, and assigning both an import and export converter. But think the concept has 3 points against it, and they take the day over the advantages.
- Maintaining the import and export parts of the converter in a single object provides a semantic and symbolic relationship between the import and export functions for a single trait. This should be encouraged to maintain a focus upon synchronicity between import and export that is central to Traitorous.
- having to define both an import converter and an export converter makes the trait api less attractive.
- It is really easy to write a custom converter in which you can implement whatever logic you need to meet your needs.
- The domain knowledge on how to export belongs in the class or object of the model.
Traitorous::Converter::Value
The Value converter leaves an existing value alone, but may insert a default value if no value exists. The ability to maintain nil vs false values is unimportant to me and thus unaddressed. I highly recommend a small custom class instead. Or submit a PR.
MISSING - Traitorous::Converter::Proc
The Proc converter would take a block in the initialize `that thes a value gets applied to
Traitorous::Converter::Model
This converter calls an import_method on a klass and passes the data as attributes. Villain.send(:new, data), URI.send(:parse, data). This is meant to provide class instantiation, complex conversion behavior, and service object handling. On export the export_method is called om the data. data.send(:export), data.send(:to_s).
Traitorous::Converter::Array
This converter imports an array of data and then uses klass and import_method as
with the Converter::Model. On export, the each element has the export_method
called on it and returns the transformed array.
data_arr.map{|e| e.send(export_method)}
Traitorous::Converter::ArrayToHash
This converter takes a key_method in addition to klass, import_method, and export methods. It takes an array of data, imports them as the Array converter does. But the sends the key_method to the imported object for use in a key/value pair.
MISSING - Traitorous::Converter::Hash
A converter that takes a hash to import, and export. The tricky thing is how the keys of the incoming hash are treated, and how the keys for the exported hash are created. One way would be to discard the keys of the incoming hash, and treat the array of values as the ArrayToHash converter does. A second way would be to maintain the incoming keys as the outgoing keys. A third way would be to expect to pass both the key and the value around as a pair making the import look like klass.send(import_method, key, value), and expecting (k,v) as output for export.
I haven't come up with a use case that has compelled me to solve this problem yet, my needs are provided for by the ArrayToHash converter.
More Converters
more intelligent conversions? Expand the model, array and hash converters to accept an override to instantiating with ::new to allow for more flexibility in usage. This especially would be important if you wanted to import a list of object that represents different klasses that are given with a sub_type or sub_class attributes that are part of the do_import data.
Maybe also set traits to accept both import and export settings? Not sure if this is necessary yet.
Roadmap
- Add better documentation
- better testing of deep constructions
- Additional Converters a. DefaultValueDynamic that stores code to run upon input or output b. VariableArray that uses a sub-type in the opts to location the correct class to instantiate instead of always using a uniform class
- Validations?
- translations?
- HashWithIndifferentAccess ## Development
I use Guard to automate testing. It won't affect anything execpt the disk space
needed to store the gems. If you do want to use it, from a shell in the home
directory of the gem and type guard.
After checking out the repo, run bin/setup to install dependencies. Then, run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release to create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.
Contributing
- Fork it ( https://github.com/[my-github-username]/traitorous/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request