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.
No type information is included in a trait. Only the Converter that will import and export it.
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'
# trait converters default to Convert.skip which is a converter that just
# passes through it's data unchanged.
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
# the Convert.default converter provides a default value if data is falsey
# the Convert.model converter instantiates the class given as a new object
# using data, if the 2nd arg == :array or [], each element of the array is
# instantiated.
class Area
include Traitorous
include Traitorous::Equality
trait :name
trait :size, Convert.default('sub-continent')
trait :ruins, Convert.model(Ruin, :array) # or Convert.array(Convert.call_on(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.
Basic Procs
Each converter is really just a thin wrapper around a pair of procs (or other object that responds to .call(data).
Each proc takes data, and when called, converts that data into something else
StandardProcs.noop
The noop proc simply returns the value passed to it.
# proc{|data| data}
StandardProcs.default(default_value)
The default proc provides a default value if data is falsey. `StandardProcs.default(:default_value) # proc{|data| data || :default_value }
StandardProcs.call_on_self(method_name)
The call_on_self proc calls method_name on data.
StandardProcs.call_on_self(:intern) # proc{|data| data.send(:intern) }
StandardProcs.call_on_self(:intern) # proc{|data| data.send(:to_s) }
StandardProcs.call_on(klass, with_method: :new)
The call_on proc uses data as params for a method call.
StandardProcs.call_on(Pathname) # proc{|data| Pathname.send(:new,data)}
StandardProcs.call_on(URI, :parse) # proc{|data| URI.send(:parse,data)}
StandardProcs.map(&block)
The map proc converts each element of Array(data) using block.
StandardProcs.map{|data| URI.send(:parse,data)}
StandardProcs.inject(memo_obj)(&block)
The map proc converts each element of Array(data) uses a memo_obj using block.
StandardProcs.inject({}){|memo,data| memo[data.name] = data}
Custom
A proc is very easy to create, creating simple procs to do various tasks can simply many complex tasks
dt_proc = proc{|data| Time.now()}
uri_proc = proc{|data| URI.parse(data)}
uri_out_proc = proc{|data| data.to_s }
tag_proc = proc{|data| data.split(/,/)
tag_join_proc = proc{|data| data.join(',')}
Basic Converters
The converters provided by the Convert module use various of the StandardProcs to achieve commonly used patterns.
The Converter itself is a simple object that takes an importer, and an optional
exporter and the provides a thin wrapper for .import(data)
and .export(data)
Convert.noop
This is the default converter for trait, both importer and exporter are
StandardProcs.noop
. The primary purpose of this is so that data that doesn't
need conversion have a simple converter that conforms to the same API as other
converters.
Convert.default(default_value)
This provides a StandardProcs.default(default_value)
importer, and a
StandardProcs.noop
exporter.
Currently there isn't a way to skip nil or empty values on export, and so I've
never had to make a decision about a more complex .export(data)
proc for this
converter. Changing the signature to StandardProcs.default(default_value, :include_on_export)
or it's logical opposite. But I'm not sure which default behaviour is better.
Convert.model(model_name, container = :scalar)
This is a pure convenience method that provides simple Object instantiaion
for single objects, arrays, and hash values. container may be :scaler, :array,
:hash. The container simply does a proc{|data| model_name.new(data)}
on
the single scalar, on each element of an array, or on each value of a hash.
# common uses
Convert.model(Pathname) # Pathname.new(data)
Convert.model(CustomClass, :array) # [CustomClass<#...>,CustomClass<#...>,...]
Convert.model(CustomClass, :hash) # {'orig_key_1' => CustomClass<#...>,'orig_key_2' => CustomClass<#...>...}
Convert.call_on_self(with_method, export_with: :itself)
This provides an StandardProcs.call_on_self(with_method)
importer and
StandardProcs.call_on_self(export_with)
exporter
Currently there is no way to pass additional arguments on the method call. It should be easy enough to create a custom Converter if you need to do that.
# common uses
Convert.call_on_self(:intern, export_with: :to_s)
Convert.call_on_self(:to_i)
Convert.call_on(klass, with: :new, export_with: :export)
This provides a StandardProcs.call_on(klass, with_method: with)
importer
and a StandardProcs.call_on_self(:export)
exporter.
Currently there is no way to pass additional arguments on the method call. It should be easy enough to create a custom Converter if you need to do that.
# common uses
c = Convert.call_on(Pathname) # Pathname.new(data)
c = Convert.call_on(URI, :parse) # URI.parse(data)
c = Convert.call_on(CustomClass, with: :import) # CustomClass.import(data)
Convert.array(converter)
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