We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
August 13, 2024
A Closer Look at Default_Scope
EMIR VATRIC
đź‘‹ Hi there!
default_scope
is a method provided by ActiveRecord, which allows you to set a default scope (as its name implies) for all operations done on a given model.
Is it evil or is it just misused?
Some years ago, I had this amazing idea for a startup, but since it is a tech company it required some coding and actually creating a product, “so what” I thought, let's learn it, after all, how difficult can it really be. After 3 years of learning and working as a Ruby developer in the industry, I forgot about my project and actually fall in love with the craft itself, but a few months ago I decided that as a side project I should start working on it, so it began.
Few years of experience and a few books later I was ready to get my hands dirty, but first things first, the app that I am building has multitenant architecture, so the app needs to have a nice, easy to use and efficient way of scoping the data by a tenant, “easy” I thought, default_scope is here, but little did I know that community hated it for its shortcomings, so let's get into it.
First, let us get familiar with the scopes in general, this will be a quick overview of parts that are relevant to the main topic.
What are scopes?
Scopes are a great Rails tool to keep stuff DRY and well organized. It’s not complicated though, it’s just a set of pre-defined queries that can easily be chained to build complex queries.
Scopes are custom queries that you define inside your Rails models with thescope
method. They take two arguments, a name, and a lambda which implements the query.
So by definition, a simple scope would look like this:
class Product < ApplicationRecord
scope :published, -> { where(published: true) }
end
So, now we have created a scope, which allows us to move this simple query from views and controller to a model, also it keeps our code nice and DRY. Like Ruby class methods, to invoke the named scopes, simply call it on the class object. Named scopes always return an ActiveRecord::Relation
object.
class Product < ApplicationRecord
scope :published, -> { where(published: true) }
end
# before
Product.where(published: true)
# now
Product.published
There is a lot more to the scopes and scopes aren’t doing anything magical or super special. They’re just methods. You could do the same thing using class methods!
What is a default_scope?
Default scopes, when defined, are automatically applied to the model, and if you try googling them you will find quite a few articles on why not to use them with some great points and examples, so let's go through some of them.
First, let’s see how it is defined in our trusty example:
class Product < ApplicationRecord
default_scope { where(published: true) }
end
# result
Product.all # Fires "SELECT * FROM products WHERE published = true"
At the first glance, this seems wrong, and it is, we are changing the expected behavior, and after a few months, you might be left confused with queries that are coming from Product and it will cost you the time to debug and ultimately refactor this code. But it doesn't end there, we still have a few more consequences of using default scopes.
The default_scope
is also applied while creating/building a record. It is not applied while updating a record.
class Product < ApplicationRecord
default_scope { where(published: true) }
end
# result
Product.new # Initializes
As we can see initializing or creating a new record will be affected by the scope unless overridden. The default scope can get really complicated when using associations and trying to get all the associated records unscoped.
Let's consider an example:
class Product < ApplicationRecord
default_scope { where(published: true) }
belongs_to :user
end
class User < ApplicationRecord
has_many :products
end
# default
User.first.products # SELECT "products".* FROM "products" WHERE "products"."published" = 't'
# AND "products"."user_id" = ? [["user_id", 1]]
# unscoped
User.first.products.unscoped # SELECT "products".* FROM "products"
This is an issue, there is a reasonable use-case where you would want to use unscoped data. Here we can find a few solutions for this issue produced from the community, but none of them seemed like an ideal solution for the issue.
Some examples include, but they usually lead to even more issues down the line and pose even greater issues to the maintainability of the code.
belongs_to :user, -> { unscope(where: :published) }
At first look, this seems to be resolving the issue, but is introducing a new one, what if I want to query published products from a user, should I define a new scope, should I not?
All of these issues could be avoided by using named scopes, and this is a prime example where default_scope should not be used, but is there a case where we can justify issues that we will be introducing to our code?
When to use it?
Inmy opinion, yes, there are cases when separating the data is extremely important, and using unscoped records would be an exception from the rule. We can look at two examples, first, you are building a multitenant, and second, you primarily in 99% of the cases want to show that specific set of data, like in cases when you would be using act_as_paranoid gem.
So how do these gems go about implementing default_scope ( since that is what they are using in the background), acts_as_tenant does a pretty good job of scoping you data per tenant and they outline it their documentation on how to use their default scope:
Adding
to your model declaration will scope that model to the current tenant BUT ONLY if a current tenant has been set.
acts_as_tenant
So how did they approach the issues around the current scope?
default_scope lambda {
if ActsAsTenant.configuration.require_tenant && ActsAsTenant.current_tenant.nil? && !ActsAsTenant.unscoped?
raise ActsAsTenant::Errors::NoTenantSet
end
if ActsAsTenant.current_tenant
keys = [ActsAsTenant.current_tenant.send(pkey)].compact
keys.push(nil) if options[:has_global_records]
query_criteria = {fkey.to_sym => keys}
query_criteria[polymorphic_type.to_sym] = ActsAsTenant.current_tenant.class.to_s if options[:polymorphic]
where(query_criteria)
else
ActiveRecord::VERSION::MAJOR < 4 ? scoped : all
end
}
As we can see here ( unless I am missing something ) they don’t, they understand that scoping data is far more important than 1% cases where you would want to get a different set of data, and the same goes for gems like act_as_paranoid. And for initializing new objects with a tenant already set makes a ton of sense.
And in the few cases when we need to use the data out of that default scope we can use unscope or with_exclusive_scope.
Conclusion
Using default scope, in general, should be avoided, more often than not it will cause issues down the line, it introduces a lot of unexpected behavior when used incorrectly.
But in those cases where it makes sense don't be afraid to use it, just make sure it is well documented.