Rails scope anti-patterns and how to eliminate them

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 that expresses the use case is a land mine. Replace with query object

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.

The advantage of scope is the abstraction of the query

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

Only ActiveRecord :: Relation should be set as the return value of scope

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

Changed.

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.

scope is not an alias for a class method

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.

Summary

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.

References

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

Rails scope anti-patterns and how to eliminate them
[Rails] How to use Scope
How to write Rails
How to uninstall Rails
How to use scope and pass processing (Jakarta)
How to build API with GraphQL and Rails
[Rails] How to get success and error messages
[Java] Types of comments and how to write them
[Rails] How to edit and customize devise view and controller
Common problems with WSL and how to deal with them
[rails] How to post images
[Rails] How to use enum
[Rails] How to install devise
[Rails] How to use enum
How to read rails routes
How to use rails join
How to terminate rails server
How to write Rails validation
How to write Rails seed
[Rails] How to use validation
[Rails] How to disable turbolinks
[Rails] How to use authenticate_user!
[Rails] How to use "kaminari"
[Rails] How to implement scraping
[Rails] How to make seed
How to write Rails routing
[Rails] How to install simple_calendar
[Rails] How to install reCAPTCHA
[Rails] How to define macros in Rspec and standardize processing
[Rails] Differences between redirect_to and render methods and how to output render methods
How to set and describe environment variables using Rails zsh
How to run React and Rails on the same server
[Rails] How to use gem "devise"
How to use StringBurrer and Arrays.toString.
How to deploy jQuery on Rails
[Rails] How to install Font Awesome
[Rails] How to use flash messages
[rails] How to display db information
How to use EventBus3 and ThreadMode
[Rails] How to write in Japanese
[Rails] How to prevent screen transition
How to use Ruby on Rails
How to call classes and methods
[Rails] How to add new pages
How to connect Heroku and Sequel
How to convert LocalDate and Timestamp
Rails on Tiles (how to write)
[Rails] How to write exception handling?
[Rails] How to install ImageMagick (RMajick)
[Rails] How to install Font Awesome
[Rails] How to use Active Storage
[Rails] How to implement star rating
How to return Rails API mode to Rails
How to install Swiper in Rails
[Rails] How to get the URL of the transition source and redirect
[Webpacker] Summary of how to install Bootstrap and jQuery in Rails 6.0
[Rails] How to introduce kaminari with Slim and change the design
[Rails] How to upload images to AWS S3 using Carrierwave and fog-aws
How to delete large amounts of data in Rails and concerns
[Rails] How to upload images to AWS S3 using refile and refile-s3
[Reading impression] "How to learn Rails, how to write a book, and how to teach"