Faceter
Experimental ROM-compatible data mapper, based on the transproc gem.
No monkey-patching, no mutable instances of any class.
100% mutant-covered.
Motivation
Basicaly the gem does about the same as the ROM mappers do. But its DSL has a different semantics:
Unlike ROM mappers, that describes the structure of transformed data with nouns (
attribute :foo, from: :bar
), theFaceter
DSL uses verbs to describe transformations (rename :bar, to: :foo
).Faceter
transformations are either "do this, from: that", or "change this, to: that".Unlike ROM, that mixes transformations (
attribute :bar, from: :foo
) with structures (wrap(:foo) { attribute :bar }), theFaceter
strictly separates them. It uses 2 nouns:list
anfield
(likeembedded
in ROM) to browse the data, and verbs to transform them. Only browsing can be nested and should end up with some transformation. Nested transformations are not supported.Due to stronger separation between browsing an transforming data, every command in
Faceter
is executed as a singlestep
in ROM. The output of every transformation serves the input for the next one. This makes it possible to read mapper DSL as a set of instructions: "go to this level and change data in this way, then go to that level etc.".Because
Faceter
uses one-step transformations, it supports inline syntax for the transformations only. Blocks are used either to browse the data, or to provide value for thecreate
command.When you inherit a mapper, the subclass will do all the transformations from its superclass and then add its own. So the inheritance can be used to deepen transformation as an alternative to chaining mappers.
The Faceter
is the experimental gem. I've wrote it to check whether this "concept" of procedure-like syntax would work fine and not overkill the recipy with too much details.
Synopsis
To access the data the mapper DSL has two methods, that can be nested deeply:
list
- "do something with every element of array"field
- "do something with a value of hash key"
The mapper also has methods to transform the accessed data (either a field of some tuple or values of some array):
rename
to rename fields in a tuplecreate
andexclude
to provide a new field from other ones (while keeping those fields unchanged) or exclude the existing one(s).add_prefix
andremove_prefix
to add/remove a prefix to names of fieldstringify_keys
andsymbolize_keys
to stringify/symbolize all keys from given layer and deeperwrap
andunwrap
to wrap a value to nested tuple or unwrap it from a tuplegroup
andungroup
to group/ungroup tuples in array by some field(s)
Suppose you need to transform array of nested data:
source = [
{
id: 1, name: 'Joe', roles: ['admin'],
emails: [
{ address: '[email protected]', type: 'job' },
{ address: '[email protected]', type: 'job' },
{ address: '[email protected]', type: 'personal' }
]
}
]
To create a mapper, include Faceter
to the mapper class and define the sequence of transformations. Transformations will be applied step-by-step to data at a corresponding level.
require "faceter"
class Mapper
include Faceter
list do
field :roles do
list { fold to: :role }
end
rename :emails, to: :contacts
end
# Both `ungroup` and `group` work with arrays as a whole. You haven't
# wrap them to the `list` unless your data are not the arrays of arrays.
ungroup :role, from: :roles
group :id, :name, :contacts, to: :user
list do
field :user do
field :contacts do
list { rename :address, to: :email }
group :address, to: :emails
end
end
end
end
To apply transformation to the source initialize the mapper and send the source array to its call
instance method:
mapper = Mapper.new
mapper.call source
# => [
# {
# role: 'admin',
# users: [
# {
# id: 1,
# name: 'Joe',
# contacts: [
# { type: 'job', emails: ['[email protected]', '[email protected]'] },
# { type: 'personal', emails: ['[email protected]'] }
# ]
# }
# ]
# }
# ]
Instantiating Models
You can do any data transformations using create
method, that does the following:
- takes values from tuple by keys given in
:from
option, - sends those values to the block in the same order,
- assigns the result of the block either to given key, or rewrites the whole tuple with given value.
require "faceter"
require "ostruct"
class Mapper
include Faceter
list do
create from: [:id, :name, :email] do |id, name, email|
OpenStruct.new id: id, name: name, email: email
end
end
end
Alternatively, you could wrap the necessary keys and then create new value with the same key:
require "faceter"
require "ostruct"
class Mapper
include Faceter
list do
wrap to: :user # wraps all keys in every tuple to the :user key
create from: :user do |user|
OpenStruct.new(user)
end
end
end
Both the examples transform the array of tuples:
source = [
{ id: 1, name: "Joe", email: "[email protected]" },
{ id: 2, name: "Jane", email: "[email protected]" }
]
into the array of instances:
mapper = Mapper.new
mapper.call(source)
# => [
# #<OpenStruct @id=1, @name="Joe", @email="[email protected]">,
# #<OpenStruct @id=2, @name="Jane", @email="[email protected]">
# ]
Data Serialization
In just the same way you can serialize models at the mapper layer:
source = [
OpenStruct.new(id: 1, name: "Joe", email: "[email protected]"),
OpenStruct.new(id: 2, name: "Jane", email: "[email protected]")
]
class Mapper < Faceter::Mapper
list do
# take every item in a list, create the value and assign it back to the item
create do |item|
{ id: item.id, name: item.name, email: item.email }
end
end
end
mapper = Mapper.new
mapper.call source
# => [
# { id: 1, name: "Joe", email: "[email protected]" },
# { id: 2, name: "Jane", email: "[email protected]" }
# ]
ROM-compatibility
To use the mapper in ROM you can register it as a custom mapper for the corresponding relation:
setup = ROM.setup :memory
setup.relation(:users)
setup.mappers { register(:users, my_mapper: mapper) }
rom = ROM.finalize.env
rom.relation(:users).as(:my_mapper).to_a
# => returns the converted data
Installation
Add this line to your application's Gemfile:
# Gemfile
gem "faceter"
Then execute:
bundle
Or add it manually:
gem install faceter
Compatibility
Tested under rubies compatible to MRI 2.1+. JRuby is supported from the head version only.
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
Credits
This project is heavily inspired by and based on gems written by Piotr Solnica:
License
See the MIT LICENSE.