Logically Negating an ActiveRecord scope

July 8, 2013 Matthew Parker

If you simply want to know how to negate an ActiveRecord scope, and you don’t care how it works, here’s the TL;DR:

scope(:not), ->(scope) { where(scope.where_values.reduce(:and).not) }

Arel is the powerful library that powers the Rails ActiveRecord library. It’s also black magic (and largely undocumented black magic at that). It was created in isolation from Rails, and later retrofitted into ActiveRecord. Prepare yourself for a crash course in the dark arts of Arel.

Imagine you’re creating a blog, and you needed a scope that would show you all of the posts that were recently tagged:

class Post < ActiveRecord::Base   scope :recently_tagged, -> {
    where(
      arel_table[:tagged_at].gt(10.days.ago)
    ).
    where(arel_table[:tags].not_eq(nil))
  }
end

We’ve chained two `where` clauses together with ActiveRecord, which will join them together with `AND`. In other words, a post that has tags and has had its tags changed within the last 10 days is “recently tagged”. Freed of the syntactic trappings of ActiveRecord and Arel, it can be simply represented with the following boolean expression:

tagged_at > 10.days.ago && tags != nil

Now suppose you’d like to find the opposite: the list of all posts that were not recently tagged (either because their tags haven’t changed in the last 10 days, or because all of their tags were removed). In boolean logic, this is simply the logical negation of the “recently_tagged” scope:

!(tagged_at > 10.days.ago && tags != nil)

which can be simplified to:

tagged_at <= 10.days.ago || tags == nil

The question is, how do we tell ActiveRecord / Arel to negate a scope? The answer is more difficult than you might expect.

Let’s start with looking at what a scope is:

> Post.recently_tagged.class
 => ActiveRecord::Relation::ActiveRecord_Relation_Post

That seems slightly odd. Apparently ActiveRecord generates a class for a scope. Let’s look at what this class is made of:

ActiveRecord::Relation::ActiveRecord_Relation_Post.ancestors
 => [ActiveRecord::Relation::ActiveRecord_Relation_Post, ActiveRecord::Delegation::ClassSpecificRelation, ActiveRecord::Relation, ActiveRecord::Relation::DeprecatedMethods, …...........]

Not suprisingly, it looks like ActiveRecord_Relation_Post is a type of ActiveRecord::Relation. If you peek inside ActiveRecord::Relation, you’ll see it’s a sort of wrapper around Arel (along with a few other ActiveRecord classes and modules). At the top of the ActiveRecord::Relation class file, there are several constants defined, including the following:

MULTI_VALUE_METHODS  = [
  :includes,
  :eager_load,
  :preload,
  :select,
  :group,
  :order,
  :joins,
  :where,
  :having,
  :bind,
  :references,
  :extending
]

This constant, along with the SINGLE_VALUE_METHODS constant, describes all of the possible constituents of an ActiveRecord scope. In our case, we want to peel off the where values of a scope so that we can logically negate them. Searching for usages of the ‘MULTI_VALUE_METHODS’ will lead you to this juicy bit of metaprogramming in ActiveRecord::QueryMethods:

Relation::MULTI_VALUE_METHODS.each do |name|
  class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}_values                   # def select_values
      @values[:#{name}] || []            #   @values[:select] || []
    end                                  # end
                                         #
    def #{name}_values=(values)          # def select_values=(values)
      raise ImmutableRelation if @loaded #   raise ImmutableRelation if @loaded
      @values[:#{name}] = values         #   @values[:select] = values
    end                                  # end
  CODE
end

ActiveRecord creates accessor methods for each of the scope’s constituent parts; since we’re interested in the where values, let’s see what `where_values` returns (note that you could also call values[:where]):

> Post.recently_tagged.where_values => [#<Arel::Nodes::GreaterThan:0x007fa759f263b0 @left=#<struct Arel::Attributes::Attribute relation=#<Arel::Table:0x007fa75d011ba0 @name="posts", @engine=Post(id: integer, tagged_at: datetime, tags: string, created_at: datetime, updated_at: datetime), @columns=nil, @aliases=[], @table_alias=nil, @primary_key=nil>, name=:tagged_at>, @right=Fri, 28 Jun 2013 05:17:39 UTC +00:00>, #<Arel::Nodes::NotEqual:0x007fa759f26018 @left=#<struct Arel::Attributes::Attribute relation=#<Arel::Table:0x007fa75d011ba0 @name="posts", @engine=Post(id: integer, tagged_at: datetime, tags: string, created_at: datetime, updated_at: datetime), @columns=nil, @aliases=[], @table_alias=nil, @primary_key=nil>, name=:tags>, @right=nil>]

Huzzah! We’ve now found the mysterious Arel. Although these class inspections don’t make it entirely obvious, these Arel nodes actually form the nodes of a tree representing boolean expressions in SQL.

The first node is:

         >
        /
       /
      /
     /
:tagged_at  10.days.ago

And the second is simply:

        !=
        /
       /
    :tags  nil

Now that we have these where values, what can we do with them? Well, we’d like to AND them back together, and then negate them. Fortunately, there are methods for both of these things on any Arel::Nodes::Node:

> Post.recently_tagged.values[:where].first.method(:not)
 #<Method: Arel::Nodes::GreaterThan(Arel::Nodes::Node)#not>
> Post.recently_tagged.values[:where].first.method(:and)
  #<Method: Arel::Nodes::GreaterThan(Arel::Nodes::Node)#and>

As you would expect, calling either of these methods will return a new Arel::Nodes::Node. In our specific case, we could call these methods directly:

> Post.recently_tagged.values[:where].first.and(Post.recently_tagged.values[:where].last).not

The `not` at the end of this expression is what we’ve been searching for. However, this returns an Arel::Nodes::Node (or more specifically, an Arel::Nodes::Not), but we’re using ActiveRecord – we need an ActiveRecord::Relation. Luckily, that’s exactly what `where` returns, and it can take, as an argument, an Arel::Nodes::Node.

> Post.where(Post.recently_tagged.values[:where].first.and(Post.recently_tagged.values[:where].last).not)

And this will, in fact, return what we’ve been searching for: all posts not recently tagged. It’s of course, incredibly ugly and not very reusable; luckily, we can create a generic negation scope with a little help from Ruby’s Enumerable#reduce method:

class Post
  #....
  scope :not, ->(scope_name) { where(send(scope_name).where_values.reduce(:and).not) }
end

And now, you could use it whenever you like:

Post.not(:recently_tagged)

About the Author

Matthew Parker

Matt Parker is Head of Engineering for Pivotal Labs

Previous
The Beauty and Curse of GitHub Issues
The Beauty and Curse of GitHub Issues

GitHub Issues features are amazing. Ticket tracking is a very hard thing to deal with – a project needs to ...

Next
Geek glossary: spy
Geek glossary: spy

So spies are pretty easy. They’re test doubles, used like mocks, but instead of setting up expectations bef...