开发者

Is it possible to negate a scope in Rails?

开发者 https://www.devze.com 2023-03-28 03:46 出处:网络
I have the following scope for my class called Collection: scope :with_missing_coins, joins(:coins).where(\"coins.is_missing = ?\", true)

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/me开发者_Python百科thod (that takes true or false as a parameter)?

Something like this:

Collection.!with_missing_coins


In Rails 4.2, you can do:

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

It'll use a subquery.


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.


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


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.


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)


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) }


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


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: []) }


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

0

精彩评论

暂无评论...
验证码 换一张
取 消