33

I am looking toward writing a scope that returns all records that do not have a particular association.

foo.rb

class Foo < ActiveRecord::Base    
  has_many :bars
end

bar.rb

class Bar < ActiveRecord::Base    
  belongs_to :foo
end

I want a scope that can find all of the Foo's that dont have any bars. It's easy to find the ones that have an association using joins, but I haven't found a way to do the opposite.

davegson
  • 8,205
  • 4
  • 51
  • 71
Julio G Medina
  • 692
  • 1
  • 7
  • 17

6 Answers6

47

Rails 4 makes this too easy :)

Foo.where.not(id: Bar.select(:foo_id).uniq)

this outputs the same query as jdoe's answer

SELECT "foos".* 
FROM "foos" 
WHERE "foos"."id" NOT IN (
  SELECT DISTINCT "bars"."foo_id"
  FROM "bars" 
)

And as a scope:

scope :lonely, -> { where.not(id: Bar.select(:item_id).uniq) }
davegson
  • 8,205
  • 4
  • 51
  • 71
  • 1
    I had to use `.pluck(:foo_id)` instead of `.select(:foo_id)`. Your way didn't work for me because calling `.select` on a model returns an `ActiveRecord_Relation` instead of just the `id`s in an array—which is what you want for the `Model.where.not(id: [1, 2, ...]` statement, right? – Bryan Dimas Feb 07 '17 at 05:18
  • 2
    Yes, while `.select` returns an ActiveRecord Relation `.pluck` returns a Ruby array. But when added as a subquery with `.select`, you only call the DB once since rails combines it with the other queries automatically. With `.pluck`, you would call the DB twice. Also, keep in mind that scopes often are chained together so using `.pluck` is *not advised*. – davegson Feb 07 '17 at 13:06
  • Maybe the reason why it didn't work for me is because I'm using SQLite (I'm working on a small program) for now. – Bryan Dimas Feb 08 '17 at 00:33
  • yeah that might be the case, if it's a small project it won't be a big issue calling the DB twice :) – davegson Feb 08 '17 at 08:37
  • 2
    In Rails 5 `uniq` was removed in favour of `distinct` so it doesn't override Ruby's native `uniq` anymore. – Andrew France Sep 25 '18 at 20:17
37

For Rails 5+ (Ruby 2.4.1 & Postgres 9.6)

I have 100 foos and 9900 bars. 99 of the foos each have 100 bars, and one of them has none.

Foo.left_outer_joins(:bars).where(bars: { foo_id: nil })

Produces one SQL query:

Foo Load (2.3ms)  SELECT  "foos".* FROM "foos" LEFT OUTER JOIN "bars" ON "bars"."foo_id" = "foos"."id" WHERE "bars"."foo_id" IS NULL

and returns the one Foo with no bars

The currently accepted answer Foo.where.not(id: Bar.select(:foo_id).uniq) is not working. It is producing two SQL queries:

Bar Load (8.4ms)  SELECT "bars"."foo_id" FROM "bars"
Foo Load (0.3ms)  SELECT  "foos".* FROM "foos" WHERE ("foos"."id" IS NOT NULL)

which returns all foos because all foos have an id that is not null.

It needs to be changed to Foo.where.not(id: Bar.pluck(:foo_id).uniq) to reduce it to one query and find our Foo, but it performs poorly in benchmarks

require 'benchmark/ips'
require_relative 'config/environment'

Benchmark.ips do |bm|
  bm.report('left_outer_joins') do
    Foo.left_outer_joins(:bars).where(bars: { foo_id: nil })
  end

  bm.report('where.not') do
    Foo.where.not(id: Bar.pluck(:foo_id).uniq)
  end

  bm.compare!
end

Warming up --------------------------------------
    left_outer_joins     1.143k i/100ms
           where.not     6.000  i/100ms
Calculating -------------------------------------
    left_outer_joins     13.659k (± 9.0%) i/s -     68.580k in   5.071807s
           where.not     70.856  (± 9.9%) i/s -    354.000  in   5.057443s

Comparison:
    left_outer_joins:    13659.3 i/s
           where.not:       70.9 i/s - 192.77x  slower
m. simon borg
  • 2,515
  • 12
  • 20
  • Thank you for the great tip! – Blue Smith Oct 19 '17 at 14:38
  • 2
    Great tip ! Could you elaborate with an "WHERE NOT EXISTS" variant ? – systho Jan 08 '18 at 10:57
  • 2
    If I understand correctly, this has been packaged up as `where.missing` in Rails 6.1+. e.g. `Foo.where.missing(:bars)` https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods/WhereChain.html#method-i-missing – Dom Christie Sep 12 '22 at 17:00
  • For those coming here trying to find orphan records in a polymorphic relationship, Rails doesn't (yet) support relationship table computation and so `where.missing` is not yet an option. (` Polymorphic associations do not support computing the class.`) – changingrainbows Aug 09 '23 at 12:21
15

in foo.rb

class Foo < ActiveRecord::Base    
  has_many :bars
  scope :lonely, lambda { joins('LEFT OUTER JOIN bars ON foos.id = bars.foo_id').where('bars.foo_id IS NULL') }
end
shweta
  • 8,019
  • 1
  • 40
  • 43
  • Since `has_many :bars` is defined on `Foo`, I think you can skip the explicit `joins(...)` in your scope and use `includes` instead, which will produce the same thing in the end: `scope :lonely, lambda { includes(:bars).where('bars.foo_id': nil)` – KenB Feb 14 '18 at 17:27
9

I prefer to use squeel gem to build complex queries. It extends ActiveRecord with such a magic:

Foo.where{id.not_in Bar.select{foo_id}.uniq}

that builds the following query:

SELECT "foos".* 
FROM "foos" 
WHERE "foos"."id" NOT IN (
  SELECT DISTINCT "bars"."foo_id"
  FROM "bars" 
)

So,

# in Foo class
scope :lonely, where{id.not_in Bar.select{foo_id}.uniq}

is what you can use to build the requested scope.

jdoe
  • 15,665
  • 2
  • 46
  • 48
  • Why's that? It's a single SQL query and its nested part should be EXTREMELY fast. – jdoe Apr 27 '12 at 18:53
  • IS there no built in AR way to accomplish a similar query ? – Julio G Medina Apr 27 '12 at 19:04
  • Nope :( You have to use text snippets to write things like `NOT IN` and combine them with a Ruby code. This combination looks ugly. Compare: `Product.where('price < ?', some_price)`(AR) vs. `Product.where{price < some_price}`(squeel). – jdoe Apr 27 '12 at 19:21
  • Say there are many million bars. You're forcing a full scan of that table (although i suppose the db might rewrite as a join) If there is no index on the foo_id column then the distinct will hurt too – Frederick Cheung Apr 27 '12 at 19:23
  • Do you really think that some king of magic-join can eliminate the seeking through all your Bars? :) – jdoe Apr 27 '12 at 19:27
6

Using NOT EXISTS with a LIMIT-ed subquery can be faster:

SELECT foos.* FROM foos
WHERE NOT EXISTS (SELECT id FROM bars WHERE bars.foo_id = foos.id LIMIT 1);

With ActiveRecord (>= 4.0.0):

Foo.where.not(Bar.where("bars.foo_id = foos.id").limit(1).arel.exists)
Sjoerd
  • 61
  • 1
  • 4
0

This method utilizes includes and allows for scope chaining. It should work with Rails 5+

scope :barless, -> {
  includes(
    :bars
  ).where(
    bars: {
      id: nil
    }
  )
}
Eric Norcross
  • 4,177
  • 4
  • 28
  • 53