Rmodel
Rmodel is an ORM library, which tends to follow the SOLID principles.
Currently works with MongoDB and SQL databases supported by Sequel.
The main thoughts of it are:
- let you models be simple and independent of the persistent layer,
- be able to switch the persistent layer at any moment,
- be able to use different databases for different entities,
- keep the simplicity of the Active Record pattern by default,
- be able to implement any type of persistence: SQL, NoSQL, files, HTTP etc.
It consists of 3 major components:
- Entities; ex.: User, Order etc.
- Repositories, which are used to fetch, save and delete entities; ex.: UserRepository, OrderRepository
- Mappers, which are used to serialize/deserialize entities to/from database tuples.
Basic implemented features:
- CRUD operations:
find
,insert
,update
,destroy
; - Scopes:
repo.fetch.recent.sorted
; - Query-based operations:
repo.fetch.recent.delete_all
.
Installation
Add this line to your application's Gemfile:
gem 'rmodel'
And then execute:
$ bundle
Or install it yourself as:
$ gem install rmodel
Usage
Let's define an entity
User = Struct.new(:id, :name, :email)
As you see it's a PORO (Plain Old Ruby Objects), a class which inherits from nothing.
It must have either the zero-argument #initialize
method or no #initialize
at all.
Of course we need a repository to save users.
require 'rmodel' # dont forget to require the gem
User = Struct.new(:id, :name, :email)
DB = Mongo::Client.new(['localhost'], database: 'test')
source = Rmodel::Mongo::Source.new(DB, :users)
mapper = Rmodel::Mongo::Mapper.new(User).define_attributes(:name, :email)
user_repository = Rmodel::Repository.new(source, mapper)
Here 3 main components of Rmodel are described:
source
points to theusers
collection withing MongoDB.mapper
is an example of a mapper class instance. It's methods#initialize
anddefine_attributes
are used to declare the mapping rules (User -> Hash and Hash -> User). It's a rather easy mapper. Every database tuple is straightforwardly converted to an instance of User with attributes :id, :name and :email. There is no need to specify :id.- Finally,
user_repository
takessource
andmapper
and makes all magic about fetching and saving users from/to the database.
CRUD
Let's create and insert several users.
john = User.new(nil, 'John', '[email protected]')
bill = User.new(nil, 'Bill', '[email protected]')
bob = User.new(nil, 'Bob', '[email protected]')
user_repository.insert(john)
user_repository.insert(bill)
user_repository.insert(bob)
Now you can check you test
database. There must be 3 new users there. Print
the john
. As you can see it's got the @id
.
p john
#<struct User id=BSON::ObjectId('...'), name="John", email="[email protected]">
Let's update John and destroy Bob.
john.name = 'John Smith'
user_repository.update(john)
user_repository.destroy(bob)
p user_repository.find(john.id) # <struct User id=BSON::ObjectId('...'), ...>
p user_repository.find(bob.id) # nil
The insert
method is polysemantic. All options below are valid.
repo.insert(object)
repo.insert([ object1, object2, object3 ])
repo.insert(object1, object2, object3)
Scopes
Scopes are defined inside the repository.
class UserRepository < Rmodel::Repository
scope :have_email do
where(email: { '$exists' => true })
end
scope :start_with do |letter|
where(name: { '$regex' => "^#{letter}", '$options' => 'i' })
end
end
repo = UserRepository.new(source, mapper)
p repo.fetch.start_with('b').to_a
Of course you can chain scopes.
p repo.fetch.start_with('b').have_email.to_a
The result of the scope is Enumerable, so you can apply the #each method and others (map, select etc).
Inside the scopes you can use any methods supported by the driver (database connection). In our case we use Origin (https://github.com/mongoid/origin) as a query builder for mongo.
Also it's possible to use scopes to run the multi-row operations.
repo.fetch.have_email.find(1) # return an object with a given id or nil
repo.fetch.have_email.delete_all # simply run the operation against the database
repo.fetch.have_email.destroy_all # extract users and run repo.destroy for the each one
repo.fetch.count # 0
If you have no scopes then just call
repo.delete_all
repo.destroy_all
Timestamps
Here is an example how to track the time, when the entity was created and updated.
require 'rmodel'
DB = Mongo::Client.new(['localhost'], database: 'test')
source = Rmodel::Mongo::Source.new(DB, :things)
class Thing
attr_accessor :id, :name, :created_at, :updated_at
end
mapper = Rmodel::Mongo::Mapper.new(Thing)
.define_attribute(:name)
.
repo = Rmodel::Repository.new(source, mapper)
thing = Thing.new
thing.name = 'chair'
repo.insert(thing)
p thing.created_at
sleep 2
thing.name = 'table'
repo.update(thing)
p thing.updated_at
To enable time tracking just add attributes created_at
and updated_at
or one of them to your entity.
Sugar methods
repo.save(thing)
repo.find!(1)
The save
method can be used instead of insert
and update
.
If the object has no not-nil id then it gets inserted. Otherwise it gets updated.
The find!
method works like the simple find
, but instead of nil it raises the Rmodel::NotFound error.
SQL repository
SQL amenities is based on the Sequel gem (http://sequel.jeremyevans.net/). So the big range of SQL databases is supported.
Sequel currently has adapters for ADO, Amalgalite, CUBRID, DataObjects, IBM_DB, JDBC, MySQL, Mysql2, ODBC, Oracle, PostgreSQL, SQLAnywhere, SQLite3, Swift, and TinyTDS.
Below you can the the example how to setup Rmodel for any supported SQL database.
require 'rmodel'
DB = Sequel.connect(adapter: 'sqlite', database: 'rmodel_test.sqlite3')
source = Rmodel::Sequel::Source.new(DB, :things)
DB.drop_table? :things
DB.create_table :things do
primary_key :id
String :name
Float :price
end
Thing = Struct.new :id, :name, :price
mapper = Rmodel::Sequel::Mapper.new(Thing).define_attributes(:name, :price)
class ThingRepository < Rmodel::Repository
scope :worth_more_than do |amount|
# use Sequel dataset filtering http://sequel.jeremyevans.net/rdoc/files/doc/dataset_filtering_rdoc.html
where { price >= amount }
end
end
repo = ThingRepository.new(source, mapper)
repo.insert Thing.new(nil, 'iPod', 200)
repo.insert Thing.new(nil, 'iPhone', 300)
repo.insert Thing.new(nil, 'iPad', 500)
p repo.fetch.count # 3
p repo.fetch.worth_more_than(400).count # 1
Embedded documents in MongoDB
Let's assume that we have the flats
collection and every documents reveals the following structure.
> db.flats.findOne()
{
"_id" : ObjectId("5632910ee5fcc32d40000000"),
"address" : "Googleplex, Mountain View, California, U.S",
"rooms" : [
{
"name" : "dining room",
"square" : 150,
"_id" : ObjectId("5632910ee5fcc32d40000001")
},
{
"name" : "sleeping room #1",
"square" : 50,
"_id" : ObjectId("5632910ee5fcc32d40000002"),
"bed" : {
"type" : "single",
"_id" : ObjectId("5632910ee5fcc32d40000003")
}
},
{
"name" : "sleeping room #2",
"square" : 20,
"_id" : ObjectId("5632910ee5fcc32d40000004"),
"bed" : {
"type" : "king-size",
"_id" : ObjectId("5632910ee5fcc32d40000005")
}
}
],
"owner" : {
"first_name" : "John",
"last_name" : "Doe",
"_id" : ObjectId("5632910ee5fcc32d40000006")
}
}
We need a rather complicated mapper to build such object. Here is the example how we can map nested embedded documents with Rmodel::Mongo::Mapper.
The idea is easy:
- define primitive mappers,
- use them in declaration of composite mappers.
require 'rmodel'
DB = Mongo::Client.new(['localhost'], database: 'test')
source = Rmodel::Mongo::Source.new(DB, :flats)
Owner = Struct.new(:first_name, :last_name)
Bed = Struct.new(:type)
Room = Struct.new(:name, :square, :bed)
Flat = Struct.new(:id, :address, :rooms, :owner)
owner_mapper = Rmodel::Mongo::Mapper.new(Owner)
.define_attributes(:first_name, :last_name)
bed_mapper = Rmodel::Mongo::Mapper.new(Bed).define_attribute(:type)
room_mapper = Rmodel::Mongo::Mapper.new(Room)
.define_attributes(:name, :square)
.define_attribute(:bed, bed_mapper)
rooms_mapper = Rmodel::ArrayMapper.new(room_mapper)
flat_mapper = Rmodel::Mongo::Mapper.new(Flat)
.define_attribute(:address)
.define_attribute(:rooms, rooms_mapper)
.define_attribute(:owner, owner_mapper)
repo = Rmodel::Repository.new(source, flat_mapper)
repo.delete_all
flat = Flat.new
flat.address = 'Googleplex, Mountain View, California, U.S'
flat.rooms = [
Room.new('dining room', 150),
Room.new('sleeping room #1', 50, Bed.new('single')),
Room.new('sleeping room #2', 20, Bed.new('king-size'))
]
flat.owner = Owner.new('John', 'Doe')
repo.insert(flat)
p repo.find(flat.id)
Entity class as a repository
Ruby on Rails lovers would like to use the entity's class to fetch, create,
update and delete objects. For example User.find(1)
, User.create(john)
instead of addressing to the repository object user_repository.find(1)
.
Rmodel has a special feature to resemble the Active Record style - the repository injector. Look at the example below.
require 'rmodel'
DB = Mongo::Client.new(['localhost'], database: 'test')
class UserRepository < Rmodel::Repository
scope :start_with do |s|
where(name: { '$regex' => "^#{s}", '$options' => 'i' })
end
def initialize
source = Rmodel::Mongo::Source.new(DB, :users)
mapper = Rmodel::Mongo::Mapper.new(User).define_attributes(:name, :email)
super(source, mapper)
end
def fetch_johns
fetch.start_with('john')
end
end
User = Struct.new(:id, :name, :email) do
include UserRepository.injector
end
User.delete_all
john = User.new(nil, 'John', '[email protected]')
User.insert(john)
john.name = 'John Smith'
User.update(john)
User.insert(User.new)
p User.fetch.start_with('J').count
p User.fetch_johns.count
Here is the explanation:
UserRepository.injector
is a module that delegates method calls to the underlying repository. It can be included to the class (like User) or extend the object.- UserRepository must have a constructor with an empty list of arguments.
- UserRepository is defined as usually. It can have scopes and custom instance
methods. All of them become available as methods of User.
- Remember
user_repo.find(1)
. Now it's available asUser.find(1)
. - The same
user.fetch.start_with('a')
becomesUser.fetch.start_with('a')
. - And the custom method is available as
User.fetch_johns
.
- Remember
Contributing
- Fork it ( https://github.com/alexei-lexx/rmodel/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