With some recent commits to rails comes the ability to have a default_scope
with a lambda that gets evaluated at query time. Combine that with a multi-tenant column scoped database structure (wow, quite the mouthful) and you've got an quick and painless way to partition your tenant data without scattering code everywhere.
Getting Started
Let’s start off with a fresh rails application.
rails new depot && cd depot
Now with our rails application ready, let's create a tenant model to control what tenants are in our application.
rails g model tenant name:string host:string
First we need to add two class methods to the Tenant
model to support storing the current tenant for each request. Here we use the current thread's local storage.
app/models/tenant.rb
class Tenant < ActiveRecord::Base
class << self
def current
Thread.current[:current_tenant]
end
def current=(tenant)
Thread.current[:current_tenant] = tenant
end
end
end
And while we're at it, we should add an index to the host
column otherwise performance will suffer significantly.
db/migrate/XXX_create_tenants.rb
class CreateTenants < ActiveRecord::Migration
def self.up
create_table :tenants
add_index :tenants, :host
end
end
Next we create our database and migrate
rake db:create db:migrate
And then we fire up a rails console and add a new tenant. What value you set the host attribute to will vary depending on your setup but you are most likely accessing the rails application using localhost
.
$ rails c
ruby > Tenant.create(:name => 'Me!', :host => 'localhost')
=> #<Tenant id: 1, name: "Me!", host: "localhost">
ruby >
Scoping our Data
To get our models to scope to only the current tenant, we need an abstract model for all the other models to inherit from (you could monkey-patch ActiveRecord::Base
but I would advise against it because you wouldn’t be able to exclude a model from tenant paritioning easily). Because the commits that enable a lambda default_scope
haven't made their way into 3-0-stable we have to add them ourselves.
Now to generate our model (--parent
is used to stop rails from generating a migration, which aren't useful for abstract models).
rails g model tenant_scoped_model --parent=active_record/base
app/models/tenant_scoped_model.rb
class TenantScopedModel < ActiveRecord::Base
self.abstract_class = true
class << self
protected
def current_scoped_methods
last = scoped_methods.last
last.respond_to?(:call) ? relation.scoping { last.call } : last
end
end
belongs_to :tenant
default_scope lambda { where('tenant_id = ?', Tenant.current) }
before_save { self.tenant = Tenant.current }
end
The last thing we need is a before_filter
in ApplicationController
that loads the tenant we want to use for this request.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_filter do
@tenant = Tenant.current = Tenant.find_by_host!(request.host)
end
end
And now we're ready to go.
Sample Model: Products
rails g scaffold product name:string description:text quantity:integer
To scope our Product
model based on the current tenant all we have to do is change what class it inherits and add the tenant_id
column to the migration (and an index to keep performance nice and high).
app/models/product.rb
class Products < TenantScopedModel
end
db/migrate/XXX_create_products.rb
class CreateProducts < ActiveRecord::Migration
def self.up
create_table :products do |t|
t.references :tenant
end
add_index :products, :tenant_id
end
end
And there you go!
See it in action
For demo purposes I have set up 4 domains: 3 that exist in the tenant table and 1 that does not. All code (including a few improvements, most notably is a custom page for missing tenant) is available in a github repository.
Caveats
- Calling
unscoped
bypasses thedefault_scope
; this is both good and bad because you can get all partitioned data from a model regardless of the current tenant. - You will get errors in the console when working with a scoped model unless you set
Tenant.current
. before_save { self.tenant = Tenant.current }
is necessary because rails does not automatically set the attributes from ourdefault_scope
because it is a lambda.
This is the 14th entry for Ruby Advent Calendar jp-en: 2010. The previous entry was written by sakuro and the next will be written by matschaffer.
I have tried this approach and seems to working fine. But I have a problem when accessing the model from another controller.
I have changed the name to AccountScoped instead of TenantScopedModel model.
Let’s say I want to get the Products so I do on my controller… @products = Products.all
But this only works fine from the ProductsController … which is really strange.
It gives me this error from any other controller:
SQLite3::SQLException: no such table: account_scopeds: SELECT “account_scopeds”.* FROM “account_scopeds” WHERE (account_id = 1)
Hope you can help me.
Thanks,
Leo!
So far I found out that is something with the default_scope, I have moved it to the Products class and it works fine. I will keep reviewing it.
Hi leo,
did you find a solution to this issue?
I can’t get this to work in Rails 3.1.0.rc4. The problem seems to be that when the lambda for the default_scope gets evaluated it triggers the computation of the table name in the context of TenantScopedModel instead of in the context of the descendent classes, which results in the wrong table name being memoized for the descendent classes.
I switched to using the ActiveRecord::Base#default_scope method (i.e. the instance method instead the class method) and everything worked for me.
The patch to current_scoped_methods is also not needed in 3.1.0.rc4
Correction. Define TenantScopedModel::default_scope (class method):
def self.default_scope
where(‘tenant_id = ?’, Tenant.current)
end
I’m getting the same issue as Leo, when the model (that is tenanted) is access from an other controller, I get a very strange error where Rails is somehow assuming that ‘TenantScopedModel’ is a table in the database …
SELECT
organiser_scoped_models
.* FROMorganiser_scoped_models
WHERE (organiser_id = 15) AND (organiser_scoped_models
.event_id = 9)Actually , I found a way to solve my issue (and Leo’s as well) by following the design in this person’s post:
http://houdiniapi.com/2011/03/simple-mongoid-multi-tenancy/
Hi ,
do we have to maintain different directories for each tenant in this application ???
How to manage it with different host name other than localhost in if the application is in Local are network.
Thanks and Regards,
Piyush S
Hi,
I hope this will be helpfull. https://github.com/kebab-project/server-ror/blob/master/README.md
Best Regards.
What’s the minimum version of rails you need to get this to work?
It worked beautifully in 3.1, but I tried it in a 3.0.7 app, and I get this: undefined method `includes_values’ for # (NoMethodError) when I try to use a scoped model, for example Model.all.