Abstract Mapper
Abstract syntax tree (AST) for domain-specific ruby mappers, based on the transproc gem.
No monkey-patching, no mutable instances. 100% mutant-covered.
Installation
Add this line to your application's Gemfile:
# Gemfile
gem "abstract_mapper"
Then execute:
bundle
Or add it manually:
gem install abstract_mapper
Usage
The gem provides the metalevel of abstraction for defining specific DSL for mappers.
All you need to provide your own mapper DSL is:
- define DSL commands as a nodes, inherited from
AbstractMapper::Branch
andAbstractMapper::Node
- define DSL-specific optimization rules for merging consecutive nodes into more efficient ones
Let's suppose we need to provide a DSL for mappers, that can rename keys in array of tuples. The following example represents an oversimplified version of the faceter gem.
Define transformations (specific nodes of AST)
Every node should implement the #transproc
method that transforms some input data to the output.
When you need attributes, assign them using virtus method attribute
:
require "abstract_mapper"
module Faceter
# The node to define a transformation of every item of the array from input
class List < AbstractMapper::Branch
# The `List#super` is already composes subnodes. All you need is to define,
# how the list should apply that composition to every item of the list
#
# Here the transformation from the transproc gem is used
def transproc
Transproc::ArrayTransformations[:map_array, super]
end
end
# The node to define a renaming of keys in a tuple
class Rename < AbstractMapper::Node
attribute :keys
def transproc
Transproc::HashTransformations[:rename_keys, keys]
end
end
end
Define optimization rules
AbstractMapper defines 2 rules AbstractMapper::Rules::Sole
and AbstractMapper::Rules::Pair
. The first one is applicable to every single node to check if it can be optimized by itself, the second one takes two consecutive nodes and either return them unchanged, or merges them into more time-efficient node.
For every rule you need to define two methods:
#optimize?
that defines if the rule is applicable to given node (or pair of nodes)#optimize
that returns either array of changed nodes, or one node, or nothing when the node(s) should be removed from the tree
Use #nodes
method to access nodes to be optimized. Base class AbstractMapper::Rules::Sole
also defines the #node
method, while AbstractMapper::Rules::Pair
defines #left
and #right
for the corresponding parts of the pair.
module Faceter
# The empty lists are useless, because they does nothing at all
class RemoveEmptyLists < AbstractMapper::Rules::Sole
def optimize?
node.is_a?(List) && node.empty?
end
def optimize
# returns nothing
end
end
# Two consecutive list branches are not a good solution, because they
# iterates twice via the same array of items in the mapped data.
#
# That's why when we meet two consecutive lists, we have to merge them
# into the one list, containing subnodes (entries) from both sources.
class CompactLists < AbstractMapper::Rules::Pair
def optimize?
nodes.map { |n| n.is_a? List }.reduce(:&)
end
def optimize
List.new { nodes.map(:entries).flatten }
end
end
# Two consecutive renames can be merged
class CompactRenames < AbstractMapper::Rules::Pair
def optimize?
nodes.map { |n| n.is_a? Rename }.reduce(:&)
end
def optimize
Rename.new nodes.map(&:attributes).reduce(:merge)
end
end
end
Register commands and rules
Now that both the nodes (transformers) and optimization rules are defined, its time to register them for the mapper.
You can coerce command argumets into node attributes. The coercer is expected to return a hash:
module Faceter
class Mapper < AbstractMapper
configure do
command :list, List
# `:foo, to: :bar` becomes `{ keys: { foo: :bar } }`
command :rename, Rename do |name, opts|
{ keys: { name => opts.fetch(:to) } }
end
rule RemoveEmptyLists
rule CompactLists
rule CompactRenames
end
end
end
Use the mapper
Now we can create a concrete faceter-based mapper, using its DSL:
require "faceter"
class MyMapper < Faceter::Mapper
list do
rename :foo, to: :bar
end
list do
# this is useless, but we have a rule just for the case
end
list do
rename :baz, to: :qux
end
end
my_mapper = MyMapper.new
my_mapper.call [{ foo: :FOO, baz: :FOO }, { foo: :BAZ, baz: :BAZ }]
# => [{ bar: :FOO, qux: :FOO }, { bar: :BAZ, qux: :BAZ }]
All the rules are applied before initializing my_mapper
, so the AST will be the following:
my_mapper.tree
# => <Root [<List [<Rename(foo: :bar, baz: :qux)>]>]>
Testing
The gem defines a collection of conventional shared examples to make RSpec tests simple and verbose.
See the list of available examples and how to use them in a wiki page.
Links
- AbstractMapper API contains some minor details.
- Faceter is an example of rich mapper.
- Transproc is a small gem that converts methods to pure transformers.
- ROM that heavily uses object mappers in its rich datastore adapters.
Credits
Many thanks to Piotr Solnica and all the rom and transproc contributors for the implementation of the rich data mapper DSL, and for the idea of even more abstract layer.
Compatibility
Tested under rubies compatible to MRI 1.9.3+.
Uses RSpec 3.0+ for testing and hexx-suit for dev/test tools collection.
Contributing
- Read the STYLEGUIDE
- Fork the project
- Create your feature branch (
git checkout -b my-new-feature
) - Add tests for it
- Commit your changes (
git commit -am '[UPDATE] Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request
License
See the MIT LICENSE.