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;
- It will invert everything before it, so
Collection.with_something.without_missing_coins
will invertwith_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
. - 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 asnull: 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
精彩评论