This article is the 23rd day of Classi Advent Calendar 2020.
Hello. @seiga. This time, I will explain the Rails scope anti-pattern and how to solve it.
** scope (named_scope) ** is a feature of Rails 2.1 that has a history of being incorporated into Rails itself from stray gems. Is it the beginning of publicity that Mr. Matsuda spoke at RubyKaigi2008? Until now, queries were constructed directly, but now the query fragments can be defined as named scopes and dynamically combined into a single set.
some_model.rb
scope :published, -> { where(published: true) }
> SomeModel.published == SomeModel.where(published: true)
> true
However, because it is convenient, if you use it easily, you will soon produce low quality code. In order to master the Rails scope, it is necessary to consider the following points.
The scope behaves as a ** public method ** for the described Model. Then, the following problems occur. Consider a pattern in which the same scope is used by two Controllers
app/models/some.rb
scope :in_some_scope, ->(param) do
.published
.eager_load(SomeBelongsToModel)
.where(some_belongs_to_model: {someparam: param})
end
app/controllers/some1_controller.rb
SomeModel.in_some_scope(param)
app/controllers/some2_controller.rb
SomeModel.in_some_scope(param)
app/models/some.rb
scope :in_some_scope, ->(param) do
.published
.eager_load(SomeBelongsToModel)
.where(some_belongs_to_model: {someparam: param})
.order(created_at: :desc)
end
In this way, ** scope is vulnerable to correction and tends to have a wide range of influence **.
The above example was a ** implicit combination ** due to the easy use of the scope that represented the use case.
Roughly speaking, a use case is a ** state dedicated to a specific one action **. If you turn this into a scope, you probably don't have a nice scope naming.
On the other hand, there are scopes ** that can be abstracted as a domain, such as published
. Since this is a scope that expresses the domain called "published", it has strong change resistance, and it is unlikely that bad effects will occur even if it is reused.
some_model.rb
#A universal "published" scope that does not depend on a particular action state
scope :published, -> { where(published: true) }
If the query is complicatedly constructed by the use case, you have the option of preparing a new scope that summarizes the scope, making it a class method of Model, and so on. However, both are publicly accessible and can be called elsewhere, creating potential joins.
The recommended method is to ** create a query object and delegate query assembly to it **. This allows you to build complex use case-dependent queries while preventing implicit joins.
app/controllers/some1_controller.rb
::Some::Some1ListQuery.call(param)
app/queries/some1/some1_list_query.rb
module Some1
class Some1ListQuery
class << self
def call(param)
SomeModel.published
.eager_load(SomeBelongsToModel)
.where(some_belongs_to_model: {someparam: param})
.order(created_at: :desc)
end
end
end
end
app/controllers/some2_controller.rb
::Some::Some2ListQuery.call(param)
app/queries/some2/some2_list_query.rb
module Some2
class Some2ListQuery
class << self
def call(param)
SomeModel.published
.eager_load(SomeBelongsToModel)
.where(some_belongs_to_model: {someparam: param})
end
end
end
end
This eliminates the implicit coupling in each use case and allows you to focus on your own use case only. By the way, in this case, make sure to access only the scope that represents the domain ** in the query object. The meaning of the query object that represented the use case is lost.
What if I want to replace a scope that has a conditional branch? In this case, you can make the same structure by preparing a chain using then.
app/queries/some/some_list_query.rb
#Get a list of public status. Title search, additional adaptation if order is specified
def call(search_tilte: nil, sort_order: nil)
SomeModel.published
.then { |relation| search_by_title(relation, search_title) }
.then { |relation| sort_by_order(relation, sort_order) }
end
private
#Name search Add if search
def search_by_title(relation, search_title)
return relation if search_title.blank?
relation.where('title LIKE ?', "%#{search_title}%")
end
#Sort by order
def sort_by_order(relation, sort_order)
case sort_order
when 'desc'
relation.order(created_at: :desc)
when 'asc'
relation.order(created_at: :asc)
else
relation
end
end
ActiveRecord :: Relation is lazy evaluation, so if you chain it like this, the query will be assembled like scope. However, at this time, if you do not return ActiveRecord :: Relation as the return value of then, you can not say that it behaves the same as scope, so be careful.
When you use scope to pull a set that represents a domain, you can ** abstract ** what columns to narrow down. This is the advantage of scope.
In other words, you can ** hide ** how to get a set that represents a domain.
But in the past
app/models/some.rb
scope :by_column_name, ->(param){ where(column_name: param) }
In some cases, a scope that describes "what operation is performed in which column" is implemented in the scope name. Certainly, it seems that you are not directly operating the Model by sandwiching the scope, but in this case, the scope caller has the knowledge of "which column to narrow down". In other words
app/controllers/some_controller.rb
SomeModel.by_column_name(param)
When
app/controllers/some_controller.rb
SomeModel.where(column_name: param)
Are exactly the same, ** not concealed and it makes no sense to scope **. It's still better to call the ActiveRecord :: Relation method directly in the Controller than to do this.
When naming a scope, think carefully about the name of the abstract set ** that represents the domain.
Worst, our VPoT will make you seppuku
You can freely set the return value in scope.
some_model.rb
scope :some, -> { 1 }
> SomeModel.some
> 1
However, it returns all only when the return value is nil or false.
some_model.rb
scope :some, -> { false }
> SomeModel.some == SomeModel.all
> true
Another advantage of scopes is that they can be chained together. However, if the return values of scope are different, you can't chain them, right? Being careful in the chain order of the scope and chaining is too overwhelming.
Also, it feels out of control that nil and false are replaced and the result of all is returned, right? Therefore, ** only ActiveRecord :: Relation should be set as the return value of scope **.
By the way, regarding the fact that it is possible to implement scope that does not return ActiveRecord :: Relation, there has been a proposal to issue a warning, but it is closed because performance will deteriorate. Instead, the documentation has settled down to ** as much as possible to return ActiveRecord :: Relation or nil **. https://github.com/rails/rails/pull/32846 https://github.com/rails/rails/issues/34532
Also, in the guide up to Rails 5.2
Every scope method always returns an ActiveRecord :: Relation object
It was confusing because it was written as if the return value was converted to ActiveRecord :: Relation on the framework side. In the Rails 6 guide
Every scope body must return ActiveRecord :: Relation or nil
It should be possible to return false to that scope body, but it seems that it is missing from the description. This will be verified and made a contribution opportunity.
Rails guide
The method settings in scope are exactly the same as the class method definition (or rather the class method definition itself).
There was a description that It was deleted.
As for the background, Answer of this stackoverflow is the axis.
It's PR merged, which modifies the wording to be * almost * same as the definition of the class method, except that the scope always returns an ActiveRecord :: Relation object.
There was already a modified description [Use Conditional Statement](https://github.com/gmcgibbon/rails/blob/8b4471a9b427f5816b4911c6879c582701546e66/guides/source/active_record_querying.md#using-conditionals It is a flow that it was deleted because it became duplicated with the contents of)
However, there is one caveat. That is, the scope always returns an ActiveRecord :: Relation object, even if the result of evaluating the conditional statement is false. This behavior is different because the class method returns nil. Therefore, if you are chaining class methods using a conditional statement and one of the conditional statements returns false, you may get a NoMethodError.
Therefore, in Rails 6.1 Rails Guide, the description that scope with arguments should be replaced with a class method is erased. This is because ** scope and class methods behave differently ** as described above.
default scope is evil This is something like another big movement and it's being beaten up, so if you don't know it, you can look it up in this section title, but we have a lot of experience with bugs caused by this and there are many opponents. I think it's convenient to use it carefully at the time of unscope and initialization, but it's tough because of the high degree of land mines ... By the way, I got a combination of nested_attributes, acts_as_paranoid and composite_primary_key, and I had a hard eye ** I have been against it because I have had it.
It seems that he was talking about half the scope while saying "to master the scope of Rails" ...? scope is certainly useful. At first glance, you can easily hide the query, so you can understand the feeling of wanting to use it a lot. However, I feel that it is easy to use anti-patterns. It is necessary to carefully consider the parts that should be scoped, such as the dosage, whether the issued query is good, and whether it creates potential risks.
https://github.com/rails/rails/blob/master/guides/source/active_record_querying.md https://nekogata.hatenablog.com/entry/2012/12/11/054555 https://qiita.com/SoarTec-lab/items/6e5f7781edf8d3fe4889 https://www.slideshare.net/moro/namedscope-more-detail-webcareer-presentation https://techracho.bpsinc.jp/hachi8833/2018_06_14/57526 https://techracho.bpsinc.jp/hachi8833/2018_11_13/64284
Recommended Posts