Хотите найти записи без связанных записей в Rails

178

Рассмотрим простую ассоциацию ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Какой самый чистый способ получить всех людей, у которых НЕТ друзей в ARel и / или meta_where?

А потом насчет has_many: через версию

class Person
   has_many :contacts
   has_many :friends, :through => :contacts, :uniq => true
end

class Friend
   has_many :contacts
   has_many :people, :through => :contacts, :uniq => true
end

class Contact
   belongs_to :friend
   belongs_to :person
end

Я действительно не хочу использовать counter_cache - и я из того, что я прочитал, не работает с has_many: through

Я не хочу извлекать все записи person.friends и проходить по ним в Ruby - мне нужен запрос / область действия, которые я могу использовать с гемом meta_search

Я не против стоимости производительности запросов

И чем дальше от реального SQL, тем лучше ...

craic.com
источник

Ответы:

110

Это все еще довольно близко к SQL, но в первом случае это должно заставить всех без друзей:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
источник
6
Представьте, что у вас есть 10000000 записей в таблице друзей. Как насчет производительности в этом случае?
goodniceweb
@goodniceweb В зависимости от вашей повторяющейся частоты, вы можете отказаться от DISTINCT. В противном случае, я думаю, вы бы хотели нормализовать данные и индекс в этом случае. Я мог бы сделать это, создав friend_idshstore или сериализованный столбец. Тогда вы могли бы сказатьPerson.where(friend_ids: nil)
Unixmonkey
Если вы собираетесь использовать sql, то, вероятно, лучше использовать not exists (select person_id from friends where person_id = person.id)(или, возможно, people.idили persons.id, в зависимости от того, какой у вас стол.) Не уверен, что самый быстрый в конкретной ситуации, но в прошлом это работало хорошо для меня, когда я не пытался использовать ActiveRecord.
nroose
442

Лучше:

Person.includes(:friends).where( :friends => { :person_id => nil } )

Для hmt это в основном то же самое, вы полагаетесь на то, что у человека без друзей также не будет контактов:

Person.includes(:contacts).where( :contacts => { :person_id => nil } )

Обновить

Есть вопрос о has_oneв комментариях, так что просто обновление. Хитрость в том, чтоincludes() ожидает имя ассоциации, но whereожидает имя таблицы. Для has_oneассоциации обычно будет выражаться в единственном числе, так что меняется, но where()часть остается такой, как есть. Так что, если Personтолько has_one :contactтогда ваше заявление будет:

Person.includes(:contact).where( :contacts => { :person_id => nil } )

Обновление 2

Кто-то спрашивал об обратном, друзья без людей. Как я прокомментировал ниже, это фактически заставило меня осознать, что последнее поле (выше :person_id:) не обязательно должно быть связано с возвращаемой моделью, оно просто должно быть полем в таблице соединений. Они все будут, nilтак что это может быть любой из них. Это приводит к более простому решению вышеперечисленного:

Person.includes(:contacts).where( :contacts => { :id => nil } )

И затем переключение на возвращение друзей без людей становится еще проще, вы меняете только класс впереди:

Friend.includes(:contacts).where( :contacts => { :id => nil } )

Обновление 3 - Rails 5

Спасибо @Anson за отличное решение для Rails 5 (дайте ему +1 к ответу ниже), вы можете использовать left_outer_joins чтобы избежать загрузки ассоциации:

Person.left_outer_joins(:contacts).where( contacts: { id: nil } )

Я включил это здесь, чтобы люди нашли это, но он заслуживает +1 для этого. Отличное дополнение!

Обновление 4 - Rails 6.1

Спасибо Tim Park за указание, что в следующей версии 6.1 вы можете сделать это:

Person.where.missing(:contacts)

Благодаря посту, на который он тоже ссылался.

smathy
источник
4
Вы можете включить это в область, которая будет намного чище.
Eytan
3
Намного лучший ответ, не уверен, почему другой оценен как принятый.
Тамик Созиев
5
Да, это так, но если у вас есть единственное имя для вашей has_oneассоциации, вам нужно изменить имя ассоциации в includesвызове. Если предположить, что он был has_one :contactвнутри, Personтогда ваш код будетPerson.includes(:contact).where( :contacts => { :person_id => nil } )
смати
3
Если вы используете пользовательское имя таблицы в модели Friend ( self.table_name = "custom_friends_table_name"), используйте Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Зек
5
@smathy Хорошее обновление в Rails 6.1 добавляет missingметод, чтобы сделать именно это !
Тим Парк
172

у smathy хороший ответ на Rails 3.

Для Rails 5 вы можете использовать, left_outer_joinsчтобы избежать загрузки ассоциации.

Person.left_outer_joins(:contacts).where( contacts: { id: nil } )

Проверьте API документы . Он был введен в запросе № 12071 .

Энсон
источник
Есть ли минусы в этом? Я проверил, и он загрузился на 0,1 мс быстрее, чем .include
Qwertie
Не загружать ассоциацию - это недостаток, если вы на самом деле получите к нему доступ позже, но выгода, если вы не получите к ней доступ. Для моих сайтов попадание в 0,1 мс является довольно незначительным, так что .includesдополнительные затраты времени загрузки не будут чем-то, что я бы сильно беспокоился по поводу оптимизации. Ваш вариант использования может отличаться.
Ансон
1
И если у вас еще нет Rails 5, вы можете сделать это: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')он также отлично работает как область. Я делаю это все время в моих проектах Rails.
Фрэнк
3
Большим преимуществом этого метода является экономия памяти. Когда вы делаете includes, все эти объекты AR загружаются в память, что может быть плохо, так как таблицы становятся все больше и больше. Если вам не нужен доступ к записи контакта, left_outer_joinsон не загружает контакт в память. Скорость запросов SQL такая же, но общее преимущество приложения намного больше.
Крисмандерсон
2
Это действительно хорошо! Спасибо! Теперь, если боги рельсов, возможно, могли бы реализовать это как простое Person.where(contacts: nil)или Person.with(contact: contact)если использовать, где посягает слишком далеко на «правильность» - но учитывая тот контакт: уже анализируется и идентифицируется как ассоциация, кажется логичным, что арл мог бы легко решить то, что требуется ...
Джастин Максвелл
14

Лица, у которых нет друзей

Person.includes(:friends).where("friends.person_id IS NULL")

Или что есть хотя бы один друг

Person.includes(:friends).where("friends.person_id IS NOT NULL")

Вы можете сделать это с помощью Arel, настроив Friend

class Friend
  belongs_to :person

  scope :to_somebody, ->{ where arel_table[:person_id].not_eq(nil) }
  scope :to_nobody,   ->{ where arel_table[:person_id].eq(nil) }
end

И тогда, Лица, у которых есть хотя бы один друг:

Person.includes(:friends).merge(Friend.to_somebody)

Без друзей

Person.includes(:friends).merge(Friend.to_nobody)
novemberkilo
источник
2
Я думаю, что вы также можете сделать: Person.includes (: друзья) .where (друзья: {person: nil})
ReggieB
1
Примечание: стратегия слияния может иногда DEPRECATION WARNING: It looks like you are eager loading table(s) Currently, Active Record recognizes the table in the string, and knows to JOIN the comments table to the query, rather than loading comments in a separate query. However, doing this without writing a full-blown SQL parser is inherently flawed. Since we don't want to write an SQL parser, we are removing this functionality. From now on, you must explicitly tell Active Record when you are referencing a table from a string
выдавать
12

Оба ответа от dmarkow и Unixmonkey дают мне то, что мне нужно - спасибо!

Я попробовал оба в моем реальном приложении и получил время для них - вот две области:

class Person
  has_many :contacts
  has_many :friends, :through => :contacts, :uniq => true
  scope :without_friends_v1, -> { where("(select count(*) from contacts where person_id=people.id) = 0") }
  scope :without_friends_v2, -> { where("id NOT IN (SELECT DISTINCT(person_id) FROM contacts)") }
end

Запустил это с реальным приложением - небольшая таблица с ~ 700 записями «Персона» - в среднем за 5 прогонов

Подход Unixmonkey ( :without_friends_v1) 813ms / запрос

Подход dmarkow ( :without_friends_v2) 891мс / запрос (на 10% медленнее)

Но потом мне пришло в голову, что мне не нужен звонок, DISTINCT()...я ищу Personзаписи с NO Contacts- поэтому они просто должны быть NOT INв списке контактов person_ids. Итак, я попробовал эту область:

  scope :without_friends_v3, -> { where("id NOT IN (SELECT person_id FROM contacts)") }

Это дает тот же результат, но в среднем 425 мс / вызов - почти вдвое меньше ...

Теперь вам могут понадобиться DISTINCTдругие похожие запросы - но в моем случае это работает нормально.

Спасибо за вашу помощь

craic.com
источник
5

К сожалению, вы, вероятно, ищете решение, включающее SQL, но вы можете установить его в области, а затем просто использовать эту область:

class Person
  has_many :contacts
  has_many :friends, :through => :contacts, :uniq => true
  scope :without_friends, where("(select count(*) from contacts where person_id=people.id) = 0")
end

Затем, чтобы получить их, вы можете просто сделать это Person.without_friends, и вы можете связать это с другими методами Arel:Person.without_friends.order("name").limit(10)

Дилан Марков
источник
1

Коррелированный подзапрос NOT EXISTS должен быть быстрым, особенно по мере того, как увеличивается число строк и соотношение дочерних и родительских записей.

scope :without_friends, where("NOT EXISTS (SELECT null FROM contacts where contacts.person_id = people.id)")
Дэвид Олдридж
источник
1

Кроме того, чтобы отфильтровать по одному другу, например:

Friend.where.not(id: other_friend.friends.pluck(:id))
Дориан
источник
3
Это приведет к 2 запросам, а не подзапросу.
grepsedawk