ruby-on-railsruby-on-rails-3activerecordexistsdefault-scope

ActiveRecord .exists?() and default_scope weirdness


We have a default_scope on our User class which restricts users to a set of companies:

class User < ActiveRecord::Base
  default_scope do
    c_ids = Authorization.current_company_ids
    includes(:companies).where(companies: { id: c_ids })
  end

  has_many :company_users
  has_many :companies, through: company_users
end

class CompanyUser < ActiveRecord::Base
  belongs_to :user
  belongs_to :company
  validates_uniqueness_of :company_id, scope: :user_id
end

class Company < ActiveRecord::Base
  has_many :company_users
  has_many :users, through: company_users
end

Calling User.last or User.find_by_email('mhayes@widgetworks.com') or User.find(55557) all work and scope as expected.

Calling User.exists?(id) throws an odd error:

Mysql2::Error: Unknown column 'companies.id' in 'where clause': SELECT  1 AS one FROM `users`  WHERE `companies`.`id` IN (4) AND `users`.`id` = 55557 LIMIT 1

Basically, if I'm getting this, it's saying that companies isn't a column on User, which it is. And if I even copy the sql into a where statement, it evaluates correctly.

User.where("SELECT  1 AS one FROM `users`  WHERE `companies`.`id` IN (4) AND `users`.`id` = 66668 LIMIT 1")

It makes me think there's an order of evaluation with default_scope and exists? is somehow called before default_scope.

If I call:

User.includes(:companies).where(companies: { id: [4] }).exists?(55557)

Works. And this is what the default_scope is doing, so I know the default_scope scope isn't failing.


Solution

  • I honestly don't know, but it looks to me like exists? builds a relation directly and decides to throw out the includes clause because exists? decides that it doesn't need to load other objects. That happens in this method call. Whereas the chained exists? has already calculated it properly from building an earlier relation (where the includes get converted into a joins).

    Maybe not a bug but probably another weirdness to add to the list with default_scope.

    Probably the best solution is to make the includes in the default_scope into an actual joins and the manually call includes whenever you actually need the full records. (Since you don't actually need them for the exists? call)

    Another possibility is just chain both includes and joins in the default_scope. I have no idea what this does to the arel calculations although I doubt much. May or May Not Work.

    Or if you just really want it to stay the same overall, you could do something crazy like:

    def self.exists?(args)
      self.scoped.exists?(args)
    end
    

    Which will basically build a relation with the default scope and then call exists? on that built relation that already performed the includes -> joins magic.