/minimapper

A minimalistic way of separating your models from ActiveRecord

Primary LanguageRuby

NOTE: This project is no longer being actively maintained

We didn't end up using it to the extent I thought and it hasn't seen much active use outside of auctionet as far as I can see.

I recommend looking at rom-rb or lotus.

Old readme below

Build Status Code Climate

Minimapper

About

Introduction

Minimapper is a minimalistic way of separating models from ActiveRecord. It enables you to test your models (and code using your models) within a sub-second unit test suite and makes it simpler to have a modular design as described in Matt Wynne's Hexagonal Rails posts.

Minimapper follows many Rails conventions but it does not require Rails.

Early days

The API may not be entirely stable yet and there are probably edge cases that aren't covered. However... it's most likely better to use this than to roll your own project specific solution. We need good tools for this kind of thing in the rails community, but to make that possible we need to gather around a few of them and make them good.

Important resources

Compatibility

This gem is tested against all major rubies in 1.8, 1.9 and 2.0, see .travis.yml. For each ruby version, the mapper is tested against SQLite3, PostgreSQL and MySQL.

Only the most basic API

This library only implements the most basic persistence API (mostly just CRUD). Any significant additions will be made into separate gems (like minimapper-extras). The reasons for this are:

  • It should be simple to maintain minimapper
  • It should be possible to learn all it does in a short time
  • It should have a stable API

Installation

Add this line to your application's Gemfile:

gem 'minimapper'

And then execute:

$ bundle

Or install it yourself as:

$ gem install minimapper

You also need the activemodel gem if you use Minimapper::Entity and not only Minimapper::Entity::Core.

Please avoid installing directly from the github repository. Code will be pushed there that might fail in CI (because testing all permutations of ruby versions and databases locally isn't practical). Gem releases are only done when CI is green.

Usage

Basics

Basics and how we use minimapper in practice.

require "rubygems"
require "minimapper"
require "minimapper/entity"
require "minimapper/mapper"

# app/models/user.rb
class User
  include Minimapper::Entity

  attributes :name, :email
  validates :name, :presence => true
end

# app/mappers/user_mapper.rb
class UserMapper < Minimapper::Mapper
  class Record < ActiveRecord::Base
    self.table_name = "users"
  end
end

## Creating
user = User.new(:name => "Joe")
user_mapper = UserMapper.new
user_mapper.create(user)

## Finding
user = user_mapper.find(user.id)
p user.name              # => Joe
p user_mapper.first.name # => Joe

## Updating
user.name = "Joey"
# user.attributes = params[:user]
user_mapper.update(user)
p user_mapper.first.name # => Joey

## Deleting
old_id = user.id
user_mapper.delete(user)
p user.id                        # => nil
p user_mapper.find_by_id(old_id) # => nil
# user_mapper.find(old_id)       # raises ActiveRecord::RecordNotFound
# user_mapper.delete_all
# user_mapper.delete_by_id(1)

## Using a repository
require "minimapper/repository"

# config/initializers/repository.rb
Repository = Minimapper::Repository.build({
  :users    => UserMapper.new
  # :projects => ProjectMapper.new
})

user = User.new(:name => "Joe")
Repository.users.create(user)
p Repository.users.find(user.id).name # => Joe
Repository.users.delete_all

## Using ActiveModel validations
user = User.new
Repository.users.create(user)
p Repository.users.count    # => 0
p user.errors.full_messages # Name can't be blank

Eager loading

When using minimapper you don't have lazy loading. We haven't gotten around to adding the association-inclusion syntax yet, but it's quite simple to implement.

Uniqueness validations and other DB validations

Validations on uniqueness can't be implemented on the entity, because they need to access the database.

Therefore, the mapper will copy over any record errors to the entity when attempting to create or update.

Add these validations to the record itself, like:

class User < ActiveRecord::Base
  validates :email, :uniqueness => true
end

Note that just calling valid? on the entity will not access the database. Errors copied over from the record will remain until the next attempt to create or update.

So an entity that wouldn't be unique in the database will be valid? before you attempt to create it. And after you attempt to create it, the entity will not be valid? even after assigning a new value, until you attempt to create it again.

Custom queries

You can write custom queries like this:

class ProjectMapper < Minimapper::Mapper
  def waiting_for_review
    entities_for query_scope.where(waiting_for_review: true).order("id DESC")
  end
end

And then use it like this:

# Repository = Minimapper::Repository.build(...)
Repository.projects.waiting_for_review.each do |project|
  p project.name
end

record_class is shorthand for ProjectMapper::Record.

query_scope is a wrapper around record_class to allow you to add custom scopes to all queries.

entity_for returns nil for nil.

entity_for and entities_for take an optional second argument if you want a different entity class than the mapper's:

class ProjectMapper < Minimapper::AR
  def owner_of(project)
    owner_record = find(project).owner
    entity_for(owner_record, User)
  end
end

Typed attributes and type coercion

If you specify type, Minimapper will only allow values of that type, or strings that can be coerced into that type.

The latter means that it can accept e.g. string integers directly from a form. Minimapper aims to be much less of a form value parser than ActiveRecord, but we'll allow ourselves conveniences like this.

Supported types: Integer and DateTime.

class User
  include Minimapper::Entity
  attributes [ :profile_id, Integer ]

  # Or for single attributes:
  # attribute :profile_id, Integer
end

User.new(:profile_id => "10").profile_id      # => 10
User.new(:profile_id => " 10 ").profile_id    # => 10
User.new(:profile_id => " ").profile_id       # => nil
User.new(:profile_id => "foobar").profile_id  # => nil

You can add your own type conversions like this:

require "date"

class ToDate
  def convert(value)
    Date.parse(value) rescue nil
  end
end

Minimapper::Entity::Convert.register_converter(:date, ToDate.new)

class User
  include Minimapper::Entity
  attributes [ :reminder_on, :date ]
end

User.new(:reminder_on => "2012-01-01").reminder # => #<Date: 2012-01-01 ...>

Minimapper only calls #convert on non-empty strings. When the value is blank or nil, the attribute is set to nil.

(FIXME? We're considering changing this so Minimapper core can only enforce type, and there's some Minimapper::FormObject mixin to parse string values.)

Overriding attribute accessors

Attribute readers and writers are implemented so that you can override them with inheritance:

class User
  include Minimapper::Entity
  attribute :name

  def name
    super.upcase
  end

  def name=(value)
    super(value.strip)
  end
end

Protected attributes

We recommend using strong_parameters for attribute security, without including ActiveModel::ForbiddenAttributesProtection.

Use of attr_accessible or attr_protected may obstruct the mapper.

If you use Minimapper as intended, you only assign attributes on the entity. Once they're on the entity, the mapper will assume they're permitted to be persisted; and once they're in the record, the mapper will assume they are permitted for populating an entity.

(FIXME?: There's a ongoing discussion about whether Minimapper should actively bypass attribute protection, or encourage you not to use it, or what.)

Associations

There is no core support for associations, but we're implementing them in minimapper-extras as we need them.

For some discussion, see this issue.

Lifecycle hooks

after_find

This is called after any kind of find and can be used for things like loading associated data.

class ProjectMapper < Minimapper::AR
  private

  def after_find(entity, record)
    entity.owner = User.new(record.owner.attributes)
  end
end

Deletion

When you do mapper.delete(entity), it will use ActiveRecord's delete, which means that no destroy callbacks or :dependent association options are honored.

(FIXME?: Should we support destroy instead or as well?)

Custom entity class

Minimapper::Entity adds some convenience methods for when a model is used within a Rails application. If you don't need that you can just include the core API from the Minimapper::Entity::Core module (or implement your own version that behaves like Minimapper::Entity::Core).

Inspiration

People

Jason Roelofs:

Robert "Uncle Bob" Martin:

Apps

  • Barsoom has built an app which at the time of writing has 16 tables mapped with minimapper. Most additions to minimapper comes from this app.

Contributing

Running the tests

You need mysql and postgres installed (but they do not have to be running) to be able to run bundle. The mapper tests use sqlite3 by default.

bundle
rake

Steps

  1. Read "Only the most basic API" above
  2. Fork it
  3. Create your feature branch (git checkout -b my-new-feature)
  4. Commit your changes (git commit -am 'Add some feature')
  5. Don't forget to write tests
  6. Push to the branch (git push origin my-new-feature)
  7. Create new Pull Request

Credits and license

By Joakim Kolsjö under the MIT license:

Copyright (c) 2012 Joakim Kolsjö

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.