Rails find record with zero has_many records associated

SqlRuby on-RailsActiverecordHas Many

Sql Problem Overview


This seems fairly simple but I can't get it to turn up on Google.

If I have:

class City < ActiveRecord::Base
  has_many :photos
end

class Photo < ActiveRecord::Base
  belongs_to :city
end

I want to find all cities that have no photos. I'd love to be able to call something like...

City.where( photos.empty? )

...but that doesn't exist. So, how do you do this kind of query?


Update: Having now found an answer to the original question, I'm curious, how do you construct the inverse?

IE: if I wanted to create these as scopes:

scope :without_photos, includes(:photos).where( :photos => {:city_id=>nil} )
scope :with_photos, ???

Sql Solutions


Solution 1 - Sql

Bah, found it here: https://stackoverflow.com/a/5570221/417872

City.includes(:photos).where(photos: { city_id: nil })

Solution 2 - Sql

In Rails versions >= 5, to find all cities that have no photos, you can use left_outer_joins:

City.left_outer_joins(:photos).where(photos: {id: nil})

which will result in SQL like:

SELECT cities.*
FROM cities LEFT OUTER JOIN photos ON photos.city_id = city.id
WHERE photos.id IS NULL

Using includes:

City.includes(:photos).where(photos: {id: nil})

will have the same result, but will result in much uglier SQL like:

SELECT cities.id AS t0_r0, cities.attr1 AS t0_r1, cities.attr2 AS t0_r2, cities.created_at AS t0_r3, cities.updated_at AS t0_r4, photos.id AS t1_r0, photos.city_id AS t1_r1, photos.attr1 AS t1_r2, photos.attr2 AS t1_r3, photos.created_at AS t1_r4, photos.updated_at AS t1_r5
FROM cities LEFT OUTER JOIN photos ON photos.city_id = cities.id
WHERE photos.id IS NULL

Solution 3 - Sql

When trying to find records with no matching records from the joined table, you need to use a LEFT OUTER JOIN

scope :with_photos, joins('LEFT OUTER JOIN photos ON cities.id = photos.city_id').group('cities.id').having('count(photos.id) > 0')
scope :without_photos, joins('LEFT OUTER JOIN photos ON cities.id = photos.city_id').group('cities.id').having('count(photos.id) = 0')

Solution 4 - Sql

I used a join to get all the ones with photos:

scope :with_photos, -> { joins(:photos).distinct }

Easier to write and understand, for that particular case. I'm not sure what the efficiency is of doing a join vs doing an includes, though

Solution 5 - Sql

I don't believe the accepted answer gives you exactly what you're looking for, as you want to do a LEFT OUTER JOIN and that answer will give you a INNER JOIN. At least in Rails 5 you can use:

scope :without_photos, left_joins(:photos).where( photos: {id: nil} )

or you can use merge in cases where namespacing will make the where clause cumbersome:

scope :without_photos, left_joins(:photos).merge( Photos.where(id: nil) )

Solution 6 - Sql

If you are not running Rails 5+ and performance is a must-have, avoid useless ActiveRecord creation and get just what you need:

City.where("NOT EXISTS(SELECT 1 FROM photos WHERE photos.city_id = cities.id LIMIT 1)")

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionAndrewView Question on Stackoverflow
Solution 1 - SqlAndrewView Answer on Stackoverflow
Solution 2 - SqlTeWuView Answer on Stackoverflow
Solution 3 - SqlYossi ShashoView Answer on Stackoverflow
Solution 4 - SqlOnikoroshiView Answer on Stackoverflow
Solution 5 - SqlskepticscriptView Answer on Stackoverflow
Solution 6 - SqlRaphaMexView Answer on Stackoverflow