viphat / til

Today I Learned
http://notes.viphat.work
0 stars 1 forks source link

[Design Patterns in Ruby] - Query Objects #240

Open viphat opened 6 years ago

viphat commented 6 years ago

Query Objects is a pattern that helps in decomposing your fat ActiveRecord models and keeping your code both slim and readable.

When to use Query Objects?

One should consider using query objects pattern when in need of performing complex queries on ActiveRecord relation. Usually using scopes for such purposes is not recommended. As a rule of thumb, if scope interacts with more than one column and/or joins in other tables, it should be considered to be moved to query object - as a side-effect we can limit amount of scopes defined in our models to a necessary minimum. Also whenever a chain of scopes is to be dealt with, using query object as well should be considered.

How to make most out of Query Object Pattern?

  1. Stick to one naming convention: For example - RecentProjectUsersQuery returns a users' relation when called.
  2. Use .call method returning a relation to call query objects
  3. Always accept relation like object as first argument - Not only is this required when using query objects as scopes but also this way we can make our query objects chain-able, which gives us an additional level of flexibility. To keep the ease of use intact, make sure to provide a default entry relation, so such query object can be used without providing an argument. It is also important to always return relation from query object with the same subject (table) as the relation query object was provided with.
  4. Provide a way to accept extra options- This can be used to customize the logic of how given query object returns its results, that may effectively turn such query object into a flexible filter. To maintain good level of readability it is recommended to only pass such options as hash / keyword arguments and always provide default values.
  5. Focus on readability of your querying method
  6. Group query objects in namespaces - To arrange our code better it is a good practice to group similar query objects into namespaces. One idea on grouping is to use name of model those queries deal with, but it can be anything reasonable. As usual, by sticking to one way of grouping query objects, it will be easy for us to decide on appropriate location for such class once we introduce a new one. Storing all of our query objects in app/queries is also recommended.
  7. Consider delegating all methods to result of .call - Using method_missing. This way a query object could be used just as a regular relation — i.e. RecentProjectUsersQuery.where(first_name: “Tony”) instead of RecentProjectUsersQuery.call.where(first_name: “Tony”)
module Users
  class WithRecentlyCreatedProjectQuery
    DEFAULT_RANGE = 2.days

    def self.call(relation = User.all, time_range: DEFAULT_RANGE)
      relation.
        joins(:projects).
        where('projects.created_at > ?', time_range.ago).
        distinct
    end
  end
end

Source

viphat commented 6 years ago

http://craftingruby.com/posts/2015/06/24/say-no-to_chained-scopes.html

Because it's easier to mock & test

class Person < ActiveRecord::Base
  enum gender: { male: 1, female: 2 }

  scope :male,        -> { where(gender: 1) }
  scope :adult,       -> { where('age >= 18') }
  scope :left_handed, -> { where(right_handed: false) }

  class << self
    def left_handed_male_adults
      left_handed.male.adult
    end
  end
end

class PeopleController < ApplicationController
  def index
    @people = Person.left_handed_male_adults

    respond_to(:html)
  end
end
class PeopleControllerTest < ActionController::TestCase
  def test_people_index
    Person.expects(:left_handed_male_adults)

    get :index
    assert_response :success
  end
end

Instead of

class Person < ActiveRecord::Base
  enum gender: { male: 1, female: 2 }
end

class PeopleController < ApplicationController
  def index
    @people = Person.where(gender: Person.genders[:male])
                    .where('age >= 18')
                    .where(right_handed: false)

    respond_to(:html)
  end
end
viphat commented 6 years ago

http://craftingruby.com/posts/2015/06/29/query-objects-through-scopes.html

class Video < ActiveRecord::Base
  scope :featured_and_popular,
        -> { where(featured: true).where('views_count > ?', 100) }
end

Can be refactor to (using Query Objects Pattern)

class Video < ActiveRecord::Base
  scope :featured_and_popular, Videos::FeaturedAndPopularQuery
end 
module Videos
  class FeaturedAndPopularQuery
    class << self
      delegate :call, to: :new
    end

    def initialize(relation = Video.all)
      @relation = relation
    end

    def call
      @relation.where(featured: true).where('views_count > ?', 100)
    end
  end
end