Хочете знайти записи без пов'язаних записів у 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 - я хочу мати запит / область, яку я можу використовувати з gem meta_search

Я не проти ціни на ефективність запитів

І чим далі від фактичного SQL, тим краще ...

Відповіді:


110

Це все ще досить близько до SQL, але в першому випадку він повинен отримати всіх, хто не має друзів:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')

6
Уявіть собі, що у вас є 10000000 записів у таблиці друзів. Що щодо продуктивності в такому випадку?
goodniceweb

@goodniceweb Залежно від частоти дублювання, ви, ймовірно, можете скинути DISTINCT. В іншому випадку, я думаю, ви хочете нормалізувати дані та індекс у такому випадку. Я можу це зробити, створивши friend_idsкрамницю чи серіалізовану колонку. Тоді ви могли б сказати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

Хтось запитав про зворотне, друзів, у яких немає людей. Як я коментував нижче, це насправді дало мені зрозуміти, що останнє поле (вище: the :person_id) насправді не повинно бути пов’язане з моделлю, яку ви повертаєтесь, воно просто повинно бути полем в таблиці приєднання. Вони все будуть, nilщоб це було будь-яким із них. Це призводить до більш простого рішення вищезазначеного:

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

А потім переключити це на повернення друзів без людей стає ще простіше, ви змінюєте лише клас спереду:

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

Оновлення 3 - рейки 5

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

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

Я включив його сюди, щоб люди знайшли його, але він заслуговує +1 для цього. Чудове доповнення!

Оновлення 4 - Рейки 6.1

Дякуємо Тім Парку , що вказали, що в майбутньому 6.1 ви можете це зробити:

Person.where.missing(:contacts)

Завдяки публікації, з якою він також посилався.


4
Ви можете включити це до сфери, яка була б набагато чистішою.
Ейтан

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 мс швидше, ніж.. Включає
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 однакова, але загальна користь від програми набагато більша.
chrismanderson

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)

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 / query

підхід dmarkow ( :without_friends_v2) 891 мс / запит (~ 10% повільніше)

Але тоді мені спало на думку, що мені не потрібен дзвінок, DISTINCT()...я шукаю Personзаписи з НІ Contacts- тому вони просто повинні бути NOT INсписком контактів person_ids. Тому я спробував цю сферу застосування:

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

Це дає той самий результат, але в середньому 425 мс / дзвінок - майже вдвічі ...

Тепер вам можуть знадобитися DISTINCTінші подібні запити - але, на мій випадок, це, здається, працює добре.

Спасибі за вашу допомогу


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

Корельований підзапит НЕ ІСНУЄТЬСЯ повинен бути швидким, особливо, коли збільшується кількість рядків і відношення дочірнього запису до батьківських записів.

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
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.