Yuval Karmi
Yuval Karmi

Reputation: 26713

Is it possible to negate a scope in Rails?

I have the following scope for my class called Collection:

scope :with_missing_coins, joins(:coins).where("coins.is_missing = ?", true)

I can run Collection.with_missing_coins.count and get a result back -- it works great! Currently, if I want to get collections without missing coins, I add another scope:

scope :without_missing_coins, joins(:coins).where("coins.is_missing = ?", false)

I find myself writing a lot of these "opposite" scopes. Is it possible to get the opposite of a scope without sacrificing readability or resorting to a lambda/method (that takes true or false as a parameter)?

Something like this:

Collection.!with_missing_coins

Upvotes: 45

Views: 34285

Answers (9)

mechnicov
mechnicov

Reputation: 15298

TL;DR

Collection.with_missing_coins.invert_where

More details

Rails 7 introduced invert_where

It allows you to invert an entire where clause instead of manually applying conditions

class User < ApplicationRecord
  scope :active, -> { where(accepted: true, locked: false) }
end

User.where(accepted: true)
# SELECT * FROM users WHERE accepted = TRUE

User.where(accepted: true).invert_where
# SELECT * FROM users WHERE accepted != TRUE

User.active
# SELECT * FROM users WHERE accepted = TRUE AND locked = FALSE

User.active.invert_where
# SELECT * FROM users WHERE NOT (accepted = TRUE AND locked = FALSE)

Be careful, compare these variants

User.where(role: "admin").active.invert_where
# SELECT * FROM users WHERE NOT (role = 'admin' AND accepted = TRUE AND locked = FALSE)

User.active.invert_where.where(role: "admin")
# SELECT * FROM users WHERE NOT (accepted = TRUE AND locked = FALSE) AND role = 'admin'

And look at this

User.where(accepted: true).invert_where.where(locked: false).invert_where
# SELECT * FROM users WHERE NOT (accepted != TRUE AND locked = FALSE)

invert_where inverts all where conditions before it. Therefore it's better to avoid using this method in scopes because it could lead to unexpected results

Much better to use this method explicitly and only in the beginning of where chain immediately after it is necessary to invert all the previous conditions

Upvotes: 5

Halil &#214;zg&#252;r
Halil &#214;zg&#252;r

Reputation: 15945

With Rails 7, you can use invert_where:

scope :with_missing_coins, -> { joins(:coins).where(coins: { is_missing: true }) }
scope :without_missing_coins, -> { with_missing_coins.invert_where }

However;

  1. It will invert everything before it, so Collection.with_something.without_missing_coins will invert with_something too, returning rows "without something". You'll need to watch out for the order of scopes and conditions: Collection.without_missing_coins.with_something.
  2. Since it inverts the condition and not the argument (ie. it converts it to != true, rather than = false), the negated scope will include null values too; unless the field is defined as null: false.

So it's probably better and clearer to write them explicitly. If you want to merge them, maybe you can use another parameterized scope:

scope :missing_coins, ->(is_missing) { joins(:coins).where(coins: { is_missing: is_missing }) }
scope :with_missing_coins, -> { missing_coins(true) }
scope :without_missing_coins, -> { missing_coins(false) }

Upvotes: 3

thisismydesign
thisismydesign

Reputation: 25122

You can do this programmatically, without a subquery:

scope :my_scope, ...

Entity.where.not(Entity.my_scope.where_values_hash)

See also: https://makandracards.com/makandra/486959-how-to-negate-scope-conditions-in-rails

Note this only works with AR queries and does not work with SQL queries. E.g. where('updated_at < ?', 1.hour.ago) would not be negated.

Upvotes: 5

AFOC
AFOC

Reputation: 844

The answer by @bill-lipa is good, but beware when the scopes you want involve associations or attachments.

If we have the following scope to select all users with a resume attached:

scope :with_resume, -> { joins(:resume_attachment) } 

The following negation will cause an exception if with_resume is empty:

scope :without_resume, -> { where.not(id: with_resume) } 
#=> users.without_resume
#=> ActiveRecord::StatementInvalid (PG::InvalidColumnReference: ERROR:  for SELECT DISTINCT, ORDER BY expressions must appear in select list

Therefore you'll need to check first to get the results you want:

scope :without_resume, -> { with_resume.any? ? where.not(id: with_resume) : where.not(id: []) }

Upvotes: 0

Harsimar Sandhu
Harsimar Sandhu

Reputation: 117

this might just work, did not test it much. uses rails 5 I guess rails 3 has where_values method instead of where_clause.


scope :not, ->(scope_name) do 
              query = self
              send(scope_name).joins_values.each do |join|
                   query = query.joins(join)
              end
              query.where((send(scope_name).
                    where_clause.send(:predicates).reduce(:and).not))
end

usage

Model.not(:scope_name)

Upvotes: 2

ZainNazirButt
ZainNazirButt

Reputation: 377

Update . Now Rails 6 adds convenient and nifty negative enum methods.

# All inactive devices
# Before
Device.where.not(status: :active)
#After 
Device.not_active

Blog post here

Upvotes: -2

numbers1311407
numbers1311407

Reputation: 34072

There's no "reversal" of a scope per se, although I don't think resorting to a lambda method is a problem.

scope :missing_coins, lambda {|status| 
  joins(:coins).where("coins.is_missing = ?", status) 
}

# you could still implement your other scopes, but using the first
scope :with_missing_coins,    lambda { missing_coins(true) }
scope :without_missing_coins, lambda { missing_coins(false) }

then:

Collection.with_missing_coins
Collection.without_missing_coins

Upvotes: 12

Ryan Bigg
Ryan Bigg

Reputation: 107728

I wouldn't use a single scope for this, but two:

scope :with_missing_coins, joins(:coins).where("coins.is_missing = ?", true)
scope :without_missing_coins, joins(:coins).where("coins.is_missing = ?", false)

That way, when these scopes are used then it's explicit what's happening. With what numbers1311407 suggests, it is not immediately clear what the false argument to with_missing_coins is doing.

We should try to write code as clear as possible and if that means being less of a zealot about DRY once in while then so be it.

Upvotes: 15

Bill Lipa
Bill Lipa

Reputation: 2079

In Rails 4.2, you can do:

scope :original, -> { ... original scope definition ... }
scope :not_original, -> { where.not(id: original) }

It'll use a subquery.

Upvotes: 83

Related Questions