DEV Community

Masataka Pocke Kuwabara
Masataka Pocke Kuwabara

Posted on

Rails 7 will introduce invert_where method, but it's dangerous

NOTE: This article based on today's latest commit of the main branch.

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

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.

Why invert_where is dangerous

The risk becomes clear with several where. For example:

puts Post.where(a: 'a').where(b: 'b').invert_where.to_sql
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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:

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)
Enter fullscreen mode Exit fullscreen mode

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 in the pull request, which implements invert_where, so I quote the comment.

Imagine, somewhere within the request flow, perhaps automatically scoped by Pundit,

current_user = User.first
posts = Post.where(author: current_user)

And then later on in a controller…

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.

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')
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)