Rails 7 will introduce invert_where method, but it's dangerous
Tags: ruby, rails
Published: 2021-05-03 03:26:25




NOTE: This article based on [today's latest commit of the main branch](https://github.com/rails/rails/commit/11a3348c2d58f448b8f1dea4f4dbb8cd5bb95a0e).


# TL;DR


* `invert_where` inverts all `where` conditions.
* It may invert unexpected conditions, so it's dangerous.



# What is `invert_where`?

`ActiveRecord::QueryMethods::WhereChain#invert_where` will be introduced since Rails 7. It inverts `where` conditions.


For example: (From the [CHANGELOG](https://github.com/rails/rails/pull/40249/files#diff-353f4f101b1bb04938d761b6e29c88e0f147084e9e276854a77ae5d9a9a3b2a8)


> ```ruby
> class User
>   scope :active, -> { where(accepted: true, locked: false) }
> end
>
> User.active
> # ... WHERE `accepted` = 1 AND `locked` = 0
>
> User.active.invert_where
> # ... WHERE NOT (`accepted` = 1 AND `locked` = 0)
> ```

It is implemented in [#40249](https://github.com/rails/rails/pull/40249).


# Why `invert_where` is dangerous

The risk becomes clear with several `where`. For example:

```ruby
puts Post.where(a: 'a').where(b: 'b').invert_where.to_sql
```

In this case, `invert_where` inverts both of the `where` conditions. So it prints the following results.

```
SELECT "posts".* FROM "posts" WHERE NOT ("posts"."a" = 'a' AND "posts"."b" = 'b')
```

As you can see, the whole of the conditions is inverted with `NOT`.


This behavior will be bugs with the following three examples.

The examples work with the following set-up code.

```ruby
require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "activerecord", github: 'rails/rails', ref: '11a3348c2d58f448b8f1dea4f4dbb8cd5bb95a0e'
  gem "activemodel", github: 'rails/rails', ref: '11a3348c2d58f448b8f1dea4f4dbb8cd5bb95a0e'
  gem "activesupport", github: 'rails/rails', ref: '11a3348c2d58f448b8f1dea4f4dbb8cd5bb95a0e'
  gem "sqlite3"
end

require "active_record"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define do
  create_table :users, force: true do |t|
    t.string :role
    t.datetime :disabled_at
  end
end
```

## 1. Using `invert_where` in a scope definition

If a scope definition includes `invert_where`, the `invert_where` affects outside of the scope.
For example:


```ruby
class User < ActiveRecord::Base
  scope :alive, -> { where(disabled_at: nil) }
  scope :disabled, -> { alive.invert_where }

  scope :admin, -> { where(role: 'admin') }
end

puts User.admin.disabled.to_sql
# => SELECT "users".* FROM "users" WHERE NOT ("users"."role" = 'admin' AND "users"."disabled_at" IS NULL)
```


In this case, `User.admin.disabled.to_sql` expects returning disabled admin users. But actually, it returns not-admin users and disabled users. It means `.admin` scope is inverted unexpectedly.

## 2. When the relation starts in a different place with `invert_where` call


It is similar to the first problem. In this case, `invert_whre` is not hidden in a scope definition, but the relation and `invert_where` are in different places.
This problem is well described in [a comment](https://github.com/rails/rails/pull/40249#issuecomment-826881180) in the pull request, which implements `invert_where`, so I quote the comment.

> Imagine, somewhere within the request flow, perhaps automatically scoped by Pundit,
> 
> ```ruby
> current_user = User.first
> posts = Post.where(author: current_user)
> ```
> 
> And then later on in a controller…
> 
> ```ruby
> published_posts = posts.where(published: true)
> draft_posts = published_posts.invert_where
> ```
> 
> The draft_posts variable is now all draft posts NOT by the current user. Putting the above logic in published and unpublished scopes, respectively, would be make this issue even more confusing to debug if it did occur.



## 3. With `default_scope`

Combination of `invert_where` and `default_scope` is surprising.


```ruby
class User < ActiveRecord::Base
  default_scope -> { where(disabled_at: nil) }

  scope :admin, -> { where(role: 'admin') }
end

puts User.admin.invert_where.to_sql
# => SELECT "users".* FROM "users" WHERE NOT ("users"."disabled_at" IS NULL AND "users"."role" = 'admin')
```

In this case, `default_scope` is used to ignore disabled users always. And `User.admin.invert_where` expects returning "available non-admin users".

But actually it displays an unexpected SQL. The query means "disabled, or not-admin users". Because `invert_where` also inverts `default_scope`.


I think `default_scope` is a bad practice. The combination with `invert_where` makes it worse.


# Conclusion


`invert_where` is dangerous, so you need to be careful if you want to use it.

If you pay close attention to the method, you can avoid the problems. But human makes mistakes, and it is not easy for beginners.
So I proposed a RuboCop rule for `invert_where` to rubocop-rails project.

https://github.com/rubocop/rubocop-rails/issues/470

But I think it still a dangerous method even if the cop is implemented. So I'm wondering if it should be reverted until releasing Rails 7.

---


This article is self-translated from [a Japanese article](https://pocke.hatenablog.com/entry/2021/04/28/233930)