Negating ActiveRecord Scopes
— 3 minute read
Updated 10 December 2021: Rails 7.0 is introducing a new #invert_where
method, which has multiple gotchas that can cause unintended side effects, but
can also be used to create a more elegant and more efficient solution to
this same problem.
Here’s a neat trick in Rails to use the plumbing of ActiveRecord to keep your
models DRY and maintainable. Say you have a scope in an ActiveRecord model with
an expires_at
column, to find records that are still active1:
class Foobar < ApplicationRecord
scope :active, -> { where(expires_at: Time.zone.now..) }
end
You also want to be able to find expired records to be able to clean them out of the database. So you may write an inverse scope:
class Foobar < ApplicationRecord
scope :active, -> { where(expires_at: Time.zone.now..) }
scope :expired, -> { where(expires_at: ...Time.zone.now) }
end
But2 what if you want “expired” records to still be usable for a brief
period to allow them to be refreshed and extended? Both scopes will need to
be changed to reflect the new ranges, or you could use a class-level utility
method that just returns 30.minutes.ago
or whatever buffer you want to
use, and call it from both scopes. Even then, if you later have need for
another opposing pair of scopes, you’ll still have to keep both of those
scopes mirrored manually as well.
Or you can leverage Arel, the magic that underlies ActiveRecord itself3:
class ApplicationRecord < ActiveRecord::Base
scope :not, ->(scope) { where(scope.arel.constraints.reduce(:and).not) }
end
Your expired
scope is now much simpler and more clearly logically coupled to
the active
scope4:
class Foobar < ApplicationRecord
scope :active, -> { where(expires_at: Time.zone.now..) }
scope :expired, -> { self.not(active) }
end
and any other opposing pairs of scopes in your application can use the same
not
meta-scope in the same way.
What’s happening under the hood
scope.arel.constraints
gets the Arel representation of the scope and
extracts the constraints (i.e. the where
clause). This gives an array of
Arel::Nodes
, one for each where
condition in the query;
.reduce(:and)
combines them all into a single Arel::Nodes::And
node that can be negated with .not
, then passed back to ActiveRecord’s
where
method, which turns the SQL for the original active
scope:
SELECT "foobars".*
FROM "foobars"
WHERE "foobars"."expires_at" >= "2020-06-03 05:44:31.319993"
LIMIT 11
into this for the expired
scope:
SELECT "foobars".*
FROM "foobars"
WHERE NOT ("foobars"."expires_at" >= "2020-06-03 05:44:31.319993")
LIMIT 11
and any changes made to the original active
scope will be used in the inverse
scope.
- Yes, ActiveRecord understands the
..
(inclusive range) and...
(range exclusive of the end value) operators for comparison queries.↩ - This is an entirely contrived example. Don’t take it too seriously.↩
- Credit to Matthew Parker for the 2013 version of this snippet.↩
self.not
is required to distinguish the method from the Ruby keyword.↩