Menu
Alfredo Motta
  • Home
  • Newsletter
  • Articles
  • About me
  • Events
  • Books
  • Research
  • Learn to code in London
Alfredo Motta

How to isolate complex queries in an object oriented fashion

Posted on January 28, 2016May 18, 2022

Building complex queries in ruby can make your code quite difficult to read, manage and reuse. In this blog post I’ll present a simple method to decorate active record objects to make your queries fun again!

The problem

Let’s assume we are building a system to search talented football players. A player has the following fields:

Player table
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  ActiveRecord::Schema.define do
    create_table :players do |t|
      t.string :forename
      t.string :surname
      t.date :birth_date
      t.string :role
      t.string :nationality
      t.string :preferred_foot
      t.integer :shoots
      t.integer :goals
      t.integer :assists
      t.integer :passes
      t.integer :successful_passes
      t.string :team_name
    end
  end

And the corresponding active record model is the following:

ActiveRecord model for the Player table
1
2
3
4
class Player < ActiveRecord::Base
  scope :right_foot, -> { where(preferred_foot: 'right') }
  scope :left_foot, -> { where(preferred_foot: 'left') }
end

Our application mostly deal with TalentHunters, objects whose responsibility is to find good players based on a search criteria. For simplicity let’s assume that a search criteria has just three components:

  • whether or not the player is part of a big team
  • whether or not the player has a good shoot accuracy
  • and whether or not the player is young.

Finally let’s also assume that our talent hunter only specialise on right foot players. The simplest, fastest implementation we can think is an object that uses the search parameters to build the query using a list of conditional statements. Here is a possible implementation:

Talent hunter using naive queries
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class TalentHunterWithNaiveQuery
  TOP_TEAMS = ['ac milan', 'real madrid', 'barcelona']
  SHOOT_ACCURACY = 0.7
  MAX_AGE = 25
 
  def initialize(options)
    @playing_for_top_team = options.fetch(:playing_for_top_team, true)
    @with_young_age = options.fetch(:with_young_age, true)
    @with_great_accuracy = options.fetch(:with_great_accuracy, true)
  end
 
  def find_good_forward
    scope = Player
 
    scope = scope.right_foot
 
    if @playing_for_top_team
      scope = scope.where('team_name IN (?)', TOP_TEAMS)
    else
      scope = scope.where.not('team_name IN (?)', TOP_TEAMS)
    end
 
    if @with_great_accuracy
      scope = scope.where('goals / shoots > ?', SHOOT_ACCURACY)
    else
      scope = scope.where.not('goals / shoots > ?', SHOOT_ACCURACY)
    end
 
    if @with_young_age
      scope = scope.where('YEAR(?) - YEAR(birth_date) < ?', Date.today, MAX_AGE)
    else
      scope = scope.where.not('YEAR(?) - YEAR(birth_date) < ?', Date.today, MAX_AGE)
    end
 
    scope
  end
end

I don’t think this is necessarily bad. We can refactor it using the Extract Method 1 or we can possibly creates scopes in the Player model to handle the different queries.

Here I’d like to present an alternative which relies on a flavour of the Query Object design pattern 2 3.

Refactoring

The key idea is to isolate the queries in a separate object without polluting the active record model with scopes. This is because queries like young, with great shoot accuracy or playing for a top team only makes sense when performing the task of talent hunting so it seems natural to keep them separate.

I am a great fan of the decorator pattern 4 to enrich objects functionality for a specific use case. For talent hunting we are looking for some sort of definition of InterestingPlayer, that behaves like a Player but have some extra scopes to make our querying life easier. Here is an attempt:

The InterestingPlayer class
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class InterestingPlayer
  TOP_TEAMS = ['ac milan', 'real madrid', 'barcelona']
  SHOOT_ACCURACY = 0.7
  MAX_AGE = 25
 
  attr_reader :scope
 
  def initialize(scope = Player)
    @scope = scope
  end
 
  def playing_for_big_team
    self.class.new @scope.where('team_name IN (?)', TOP_TEAMS)
  end
 
  def not_playing_for_big_team
    self.class.new @scope.where.not('team_name IN (?)', TOP_TEAMS)
  end
 
  def with_great_shoot_accuracy
    self.class.new @scope.where('goals / shoots > ?', SHOOT_ACCURACY)
  end
 
  def without_great_shoot_accuracy
    self.class.new @scope.where.not('goals / shoots > ?', SHOOT_ACCURACY)
  end
 
  def young
    self.class.new @scope.where('YEAR(?) - YEAR(birth_date) < ?', Date.today, MAX_AGE)
  end
 
  def old
    self.class.new @scope.where.not('YEAR(?) - YEAR(birth_date) < ?', Date.today, MAX_AGE)
  end
 
  def method_missing(method, *args, &block)
    result = @scope.send(method, *args, &block)
 
    is_a_relation?(result) ? self.class.new(result) : result
  end
 
  def respond_to?(method, include_private = false)
    super || @scope.respond_to?(method, include_private)
  end
 
  private
 
  def is_a_relation?(obj)
    obj.instance_of? relation_class_name
  end
 
  def relation_class_name
    "#{@scope.name}::ActiveRecord_Relation".constantize
  end
end

We can split the methods of this object in two sets. The first set of methods is enriching the Player active record model with domain specific queries related to the task of talent hunting. When you call one of the domain specific queries  an object of type InterestingPlayer is built and returned immediately, like here:

Domain specific queries related to talent hunting
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  def playing_for_big_team
    self.class.new @scope.where('team_name IN (?)', TOP_TEAMS)
  end
 
  def not_playing_for_big_team
    self.class.new @scope.where.not('team_name IN (?)', TOP_TEAMS)
  end
 
  def with_great_shoot_accuracy
    self.class.new @scope.where('goals / shoots > ?', SHOOT_ACCURACY)
  end
 
  def without_great_shoot_accuracy
    self.class.new @scope.where.not('goals / shoots > ?', SHOOT_ACCURACY)
  end
 
  def young
    self.class.new @scope.where('YEAR(?) - YEAR(birth_date) < ?', Date.today, MAX_AGE)
  end
 
  def old
    self.class.new @scope.where.not('YEAR(?) - YEAR(birth_date) < ?', Date.today, MAX_AGE)
  end

The second set of methods is slightly more complicated and it is the one dealing with the delegation. When InterestingPlayer receives a method call that does not match with any of its public methods we delegate the call to the underlying scope. At this point couple of things could happen:

  • The result is a relation again. In this case we just want to return another instance of InterestingPlayer to let the user compose his query further.
  • The result is not a relation, and so the query object have finished his work and we just want to return the result.

This is how this how it could be implemented:

Delegating to the underlying scope
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  def method_missing(method, *args, &block)
    result = @scope.send(method, *args, &block)
 
    is_a_relation?(result) ? self.class.new(result) : result
  end
 
  def respond_to?(method, include_private = false)
    super || @scope.respond_to?(method, include_private)
  end
 
  private
 
  def is_a_relation?(obj)
    obj.instance_of? relation_class_name
  end
 
  def relation_class_name
    "#{@scope.name}::ActiveRecord_Relation".constantize
  end

Finally, let’s have a look at how a TalentHunter use this query object:

TalentHunter relying on the extracted query object
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TalentHunterWithQueryObject
  def initialize(options)
    @playing_for_top_team = options.fetch(:playing_for_top_team, true)
    @with_young_age = options.fetch(:with_young_age, true)
    @with_great_accuracy = options.fetch(:with_great_accuracy, true)
  end
 
  def find_good_forward
    search  = InterestingPlayer.new
 
    search = search.right_foot
    search = @playing_for_top_team ? search.playing_for_big_team : search.not_playing_for_big_team
    search = @with_great_accuracy ? search.with_great_shoot_accuracy : search.without_great_shoot_accuracy
    search = @with_young_age ? search.young : search.old
 
    search
  end
end

I think this enabled two major results:

  • We extracted the domain specific queries into the InterestingPlayer object, effectively enabling reuse without polluting the general purpose active record model.
  • We made the find_good_forward method a bit easier to read because it now only uses domain terms instead of having to translated them into db queries.

Conclusions

We have seen how to decorate a simple active record model (Player) to extract queries related to a specific task into a separate object (InterestingPlayer). As a result we simplified the object responsible to build the query and arguably it is now easier to read and comprehend (TalentHunter). Code and tests are available on Github.

I’d love to hear your opinion on the comments, or just tweet me your opinion!

References

  1. Refactoring using Extract Method
  2. Query objects in Ruby
  3. Query objects in Java
  4. The decorator pattern

8 thoughts on “How to isolate complex queries in an object oriented fashion”

  1. hron84 says:
    January 28, 2016 at 1:35 pm

    You make same query for young and old players. You should swap the relation sign for the old ones.

    Reply
    1. mottalrd says:
      January 28, 2016 at 2:24 pm

      Hi hron84, thank you for the comment! I think there is a .not in front, right?

      def old
      self.class.new @scope.where.not(‘YEAR(?) – YEAR(birth_date) < ?’, Date.today, MAX_AGE)
      end

      Reply
  2. Ezequiel says:
    January 28, 2016 at 3:30 pm

    I’ll start by saying nice article.

    I would like to point some things about the article.

    This particular example talks about Search Filters. Although the code is good, query methods should be inside the model. You should not use any query methods outside your model.

    Having query methods inside your model makes the code easier to maintain, refactor and re use them.

    An outsider class should not know how to retrieve records from a model. The outsider class should tell the model, I want this records and that’s it.

    Anyway I wrote an article about a year ago talking about search filters, oriented design and gems available that do that.

    Although the example uses query methods on the search class, is just to illustrate the use of the gem. You should use ActiveRecord scopes instead of the query methods.

    Article: http://edelpero.svbtle.com/search-filters-and-object-oriented-design
    Gem: https://github.com/edelpero/lupa

    Reply
    1. mottalrd says:
      January 28, 2016 at 3:41 pm

      Hi Ezequiel, thank you for your comment! I agree that scopes should be the default way to go, but if you have a very large model with tons of scopes sometimes it is difficult to separate concerns.

      Here I just wanted to define queries that are specific for the talent search task and that may be different for another user. Back to my example a TalentHunter and another class – say JournalistReporter – may need different definitions of “young”.

      what are your thoughts to achieve that kind of query namespacing? this is what I am proposing here

      Reply
      1. Fred says:
        January 28, 2016 at 10:36 pm

        I have to agree with the author’s line of reasoning. Scopes, in general, should only be used for very simple/basic queries. For more complex, domain-specific queries, a different object should be used (Query Objects). This keeps any domain-specific concerns out the ActiveRecord, keeping responsibilities of both the ActiveRecord AND the query object small and cohesive.

        Reply
    2. Janko says:
      January 29, 2016 at 6:31 am

      I also agree with the author’s line of reasoning. Scopes are just a collections of macros for specific queries, which don’t add any behaviour to your model. They can grow very large, so your model will become cluttered with code which is irrelevant for understanding the behaviour of the model. Extracting them out in fact makes them easier to maintain, you have more freedom (e.g. creating private methods), and keeps your model cleaner. I also wrote about query objects, in my post they’re called “Finder Objects”, which should work for any ORM: http://twin.github.io/finder-objects/

      Reply
  3. Ezequiel says:
    January 29, 2016 at 6:47 pm

    If you have different definitions of young, they should not have the same name because they don’t do the same thing.

    You may need to have a “young_for_talent_hunter” and “young_for_journalist_reporter” scopes.

    Then if you want to make complex queries to combine scopes, you should use a Search Filter class, and that class should call to the scopes defined on your model.

    A Search Filter class should only know how to combine scopes, not how to combine them and retrieve them from the database, because then, you will be violating the Single Responsability Principle.

    Reply
    1. mottalrd says:
      January 30, 2016 at 8:16 pm

      Thank you for your feedback Ezequiel

      You may need to have a “young_for_talent_hunter” and “young_for_journalist_reporter” scopes.

      That’s a definitely an alternative. But I’d rather prefer to have the “InterestingPlayer” class to add a context to the young definition. Sometimes this makes a lot of sense.

      A Search Filter class should only know how to combine scopes, not how to combine them and retrieve them from the database, because then, you will be violating the Single Responsability Principle.

      I agree with that. I think the solution here still satisfy the SRP. The “TalentHunter” is what you call the search filter, the “InterestingPlayer” is a decorated active record model that groups some queries under a namespace, and “Player” is the raw active record model.

      Reply

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Answer the question * Time limit is exhausted. Please reload CAPTCHA.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Tweets by mottalrd

Recent Comments

  • mottalrd on Not So Random Software #21 – Technical debt
  • mottalrd on Not So Random Software #24 – Climate Change Tech
  • mottalrd on A/B Testing, from scratch
  • mottalrd on A/B Testing, from scratch
  • Karim on A/B Testing, from scratch
©2023 Alfredo Motta | Powered by SuperbThemes & WordPress