Traitorous Gem Version

Build Status Code Climate Inline docs

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.

  1. 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.
  2. having to define both an import converter and an export converter makes the trait api less attractive.
  3. It is really easy to write a custom converter in which you can implement whatever logic you need to meet your needs.
  4. 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

  1. Add better documentation
  2. better testing of deep constructions
  3. 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
  4. Validations?
  5. translations?
  6. 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

  1. Fork it ( https://github.com/[my-github-username]/traitorous/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request