Rails 7 is introducing a new method invert_where
that will invert all scope conditions. It allows us to invert an entire where clause instead of manually applying conditions.
We can either chain invert_where
to a scope or to a where
condition.
class Account
scope :active, -> { where(active: true) }
end
Account.active.invert_where
=> "SELECT \"accounts\".* FROM \"accounts\" WHERE \"accounts\".\"active\" != 1"
Account.where(active: true).invert_where
=> "SELECT \"accounts\".* FROM \"accounts\" WHERE \"accounts\".\"active\" != 1"
What are the various side effects of using invert_where
?
1. The invert_where
method inverts all the where clauses present in the query
Let's say we create a scope to fetch inactive accounts using invert_where
.
class Account
scope :active, -> { where(active: true) }
scope :inactive, -> { active.invert_where }
end
Now, somewhere in the controller, we want to fetch all the inactive admin accounts so we may end up with a query as follows:
Account.where(role: 'admin').inactive
We may think that the above query will return all the inactive admin accounts. Let's look at the SQL equivalent of the above code.
=> "SELECT \"accounts\".* FROM \"accounts\" WHERE NOT (\"accounts\".\"role\" = 'admin' AND \"accounts\".\"active\" = 1)"
But instead, we received all the inactive non-admin accounts. The invert_where
also inverted the clause where(role: 'admin')
internally.
2. The invert_where
affects the default scope as well
Continuing the above example, let us add a default scope in the Account model to filter archived accounts (soft-deleted accounts).
class Account
default_scope { where(archived: false) }
scope :active, -> { where(active: true) }
end
Now let us write a query to fetch inactive accounts using invert_where
.
Account.active.invert_where
The SQL equivalent of the above will be:
=> "SELECT \"accounts\".* FROM \"accounts\" WHERE NOT (\"accounts\".\"archived\" = 0 AND \"accounts\".\"active\" = 1)"
We can see that invert_where
has also inverted the default_scope
. We wanted to fetch inactive accounts which are not archived but instead received inactive accounts which are archived.
Using default_scope
is quite dangerous in the model in the first place, using invert_where
will increase the chances of bugs on top of that.
3. Challenges we will face on chaining two or more scopes that contain invert_where
Let's say we have two scopes that contain invert_where
as follows:
class Account
scope :inactive, -> { active.invert_where }
scope :non_admin, -> { admin.invert_where }
end
Now we want to fetch the inactive non-admin accounts, so we chain the above two scopes together and write the query as follows:
Account.non_admin.inactive
The SQL equivalent of the above is:
=> "SELECT \"accounts\".* FROM \"accounts\" WHERE NOT (\"accounts\".\"role\" != 'admin' AND \"accounts\".\"active\" = 1)"
We can see that the above query will unexpectedly return all the inactive admin accounts.
Substitute for invert_where
Rails 7 is still in the alpha phase, invert_where
's behavior might be changed once Rails 7 is officially released. For now, we can make use of the NOT
condition to invert the individual WHERE
clause.
Let us take the example from the first point and create a scope to fetch inactive accounts using the NOT
clause.
class Account
scope :active, -> { where(active: true) }
scope :inactive, -> { where.not(active: true) }
end
Now if we want to fetch all the inactive admin accounts, we can write the query as follows:
Account.where(role: 'admin').inactive
The SQL equivalent of the above will be:
=> "SELECT \"accounts\".* FROM \"accounts\" WHERE \"accounts\".\"role\" = 'admin' AND \"accounts\".\"active\" != 1"
The above query will return all the inactive admin accounts. This is the expected behavior we wanted and we achieved it using the NOT
clause.
Conclusion
One should cautiously use this proposed method invert_where
otherwise we may end up with bugs that may be difficult to debug. What do you think? Do you agree or have any other suggestions?
Thank you for reading.