Chcesz znaleźć rekordy bez powiązanych rekordów w Railsach

178

Rozważ proste skojarzenie ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Jaki jest najczystszy sposób na zdobycie wszystkich osób, które NIE mają przyjaciół w ARel i / lub meta_where?

A co z has_many: poprzez wersję

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

Naprawdę nie chcę używać counter_cache - i z tego, co przeczytałem, nie działa z has_many: przez

Nie chcę pobierać wszystkich rekordów person.friends i przeglądać je w Rubim - chcę mieć zapytanie / zakres, którego mogę użyć z gemem meta_search

Nie przeszkadza mi koszt wydajności zapytań

Im dalej od rzeczywistego SQL, tym lepiej ...

craic.com
źródło

Odpowiedzi:

110

Jest to nadal bardzo zbliżone do SQL, ale w pierwszym przypadku powinno przyciągnąć wszystkich bez znajomych:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
źródło
6
Wyobraź sobie, że masz 10000000 rekordów w tabeli znajomych. A co z wydajnością w takim przypadku?
goodniceweb
@goodniceweb W zależności od częstotliwości duplikatów prawdopodobnie możesz upuścić rozszerzenie DISTINCT. W przeciwnym razie myślę, że w takim przypadku chciałbyś znormalizować dane i indeks. Mogę to zrobić, tworząc friend_idshstore lub zserializowaną kolumnę. Wtedy możesz powiedziećPerson.where(friend_ids: nil)
Unixmonkey
Jeśli zamierzasz używać sql, prawdopodobnie lepiej jest użyć not exists (select person_id from friends where person_id = person.id)(A może people.idlub persons.id, w zależności od tego, jaki jest twój stół). Nie jestem pewien, jaka jest najszybsza w konkretnej sytuacji, ale w przeszłości działało to dobrze, gdy ja nie próbował użyć ActiveRecord.
nroose
442

Lepszy:

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

Hmt to w zasadzie to samo, polegasz na tym, że osoba bez przyjaciół również nie będzie miała kontaktów:

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

Aktualizacja

Mam pytanie has_onew komentarzach, więc po prostu aktualizuję. Sztuczka polega na tym, że includes()oczekuje nazwy skojarzenia, ale whereoczekuje nazwy tabeli. W przypadku a has_oneskojarzenie będzie zazwyczaj wyrażane w liczbie pojedynczej, więc zmienia się, ale where()część pozostaje taka, jaka jest. Więc jeśli Persontylko has_one :contactwtedy twoje oświadczenie byłoby:

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

Zaktualizuj 2

Ktoś zapytał o odwrotność, przyjaciół bez ludzi. Jak skomentowałem poniżej, to faktycznie uświadomiło mi, że ostatnie pole (powyżej: the :person_id) tak naprawdę nie musi być związane z modelem, który zwracasz, po prostu musi to być pole w tabeli łączenia. Wszyscy będą, nilwięc może to być każdy z nich. Prowadzi to do prostszego rozwiązania powyższego:

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

A potem przełączenie tego, aby zwrócić przyjaciół bez ludzi, staje się jeszcze prostsze, zmieniasz tylko klasę z przodu:

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

Aktualizacja 3 - Rails 5

Dzięki @Anson za doskonałe rozwiązanie Rails 5 (daj mu kilka + 1-ek za jego odpowiedź poniżej), możesz użyć, left_outer_joinsaby uniknąć ładowania skojarzenia:

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

Umieściłem go tutaj, aby ludzie go mogli znaleźć, ale zasługuje na +1 za to. Świetny dodatek!

Aktualizacja 4 - Rails 6.1.0

Podziękowania dla Tima Park za wskazanie, że w nadchodzącym 6.1 możesz to zrobić:

Person.where.missing(:contacts)

Dzięki postowi, do którego też się podlinkował

smathy
źródło
4
Możesz włączyć to do zakresu, który byłby znacznie czystszy.
Eytan
3
O wiele lepsza odpowiedź, nie wiem, dlaczego druga została oceniona jako zaakceptowana.
Tamik Soziev
5
Tak, po prostu zakładając, że masz pojedynczą nazwę dla swojego has_onestowarzyszenia, musisz zmienić nazwę stowarzyszenia w includeswezwaniu. Zakładając, że jest w has_one :contactśrodku, Persontwój kod będzie Person.includes(:contact).where( :contacts => { :person_id => nil } )
wyglądał
3
Jeśli używasz niestandardowej nazwy tabeli w modelu znajomego ( self.table_name = "custom_friends_table_name"), użyj Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek
5
@smathy Aktualizacja miły w Rails 6.1 dodaje missingmetodę dokładnie zrobić to !
Tim Park
172

Smathy ma dobrą odpowiedź Rails 3.

W przypadku Rails 5 możesz użyć, left_outer_joinsaby uniknąć ładowania skojarzenia.

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

Zapoznaj się z dokumentacją API . Został wprowadzony w żądaniu ściągnięcia nr 12071 .

Anson
źródło
Czy są jakieś wady? Sprawdziłem i ładowało się o 0,1 ms szybciej. Obejmuje
Qwertie
Brak ładowania powiązania jest wadą, jeśli faktycznie uzyskasz do niego dostęp później, ale korzyścią, jeśli nie masz do niego dostępu. W przypadku moich witryn trafienie 0,1 ms jest dość znikome, więc .includesdodatkowy koszt czasu ładowania nie byłby czymś , o co martwiłbym się zbytnio optymalizacją. Twój przypadek użycia może być inny.
Anson,
1
A jeśli nie masz jeszcze Rails 5, możesz to zrobić: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')Działa również dobrze jako zakres. Robię to cały czas w moich projektach Railsowych.
Frank
3
Dużą zaletą tej metody jest oszczędność pamięci. Kiedy to zrobisz includes, wszystkie te obiekty AR są ładowane do pamięci, co może być złe, gdy tabele stają się coraz większe. Jeśli nie potrzebujesz dostępu do rekordu kontaktu, left_outer_joinsnie ładuje kontaktu do pamięci. Szybkość żądania SQL jest taka sama, ale ogólna korzyść z aplikacji jest znacznie większa.
chrismanderson
2
To jest naprawdę dobre! Dzięki! Jeśli bogowie szynowi mogliby być może zaimplementować to jako prostą Person.where(contacts: nil)lub Person.with(contact: contact)gdyby użyć tego, gdzie wkracza zbyt daleko w `` właściwość '' - ale biorąc pod uwagę ten kontakt: jest już analizowany i identyfikowany jako skojarzenie, wydaje się logiczne, że mogliby łatwo ustalić, co jest wymagane ...
Justin Maxwell
14

Osoby, które nie mają przyjaciół

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

Albo co najmniej jednego przyjaciela

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

Możesz to zrobić z Arel, ustawiając zakresy na 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

A następnie Osoby, które mają co najmniej jednego znajomego:

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

Bez przyjaciół:

Person.includes(:friends).merge(Friend.to_nobody)
novemberkilo
źródło
2
Myślę, że możesz też zrobić: Person.includes (: friends) .where (friends: {person: nil})
ReggieB
1
Uwaga: strategia scalania może czasami powodować ostrzeżenie, takie jakDEPRECATION 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
genkilabs
12

Odpowiedzi z dmarkow i Unixmonkey dają mi to, czego potrzebuję - dziękuję!

Wypróbowałem oba w mojej prawdziwej aplikacji i uzyskałem dla nich czasy - oto dwa zakresy:

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

Uruchomiłem to z prawdziwą aplikacją - małym stolikiem z ~ 700 rekordami „osób” - średnio 5 przebiegów

Podejście Unixmonkey ( :without_friends_v1) 813ms / query

podejście dmarkow ( :without_friends_v2) 891ms / zapytanie (~ 10% wolniej)

Ale wtedy przyszło mi do głowy, że nie potrzebuję telefonu do DISTINCT()...którego szukam Personpłyt z NIE Contacts- więc wystarczy, że są NOT INlistą kontaktów person_ids. Więc wypróbowałem ten zakres:

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

Daje to ten sam wynik, ale ze średnią 425 ms / połączenie - prawie połowę czasu ...

Teraz możesz potrzebować DISTINCTw innych podobnych zapytaniach - ale w moim przypadku wydaje się to działać dobrze.

Dzięki za pomoc

craic.com
źródło
5

Niestety, prawdopodobnie szukasz rozwiązania wykorzystującego SQL, ale możesz ustawić je w zakresie, a następnie po prostu użyć tego zakresu:

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

Następnie, aby je zdobyć, możesz po prostu zrobić Person.without_friends, a także możesz połączyć to z innymi metodami Arela:Person.without_friends.order("name").limit(10)

Dylan Markow
źródło
1

Podzapytanie skorelowane NIE ISTNIEJE powinno być szybkie, zwłaszcza gdy wzrasta liczba wierszy i stosunek rekordów potomnych do nadrzędnych.

scope :without_friends, where("NOT EXISTS (SELECT null FROM contacts where contacts.person_id = people.id)")
David Aldridge
źródło
1

Ponadto, aby odfiltrować na przykład według jednego znajomego:

Friend.where.not(id: other_friend.friends.pluck(:id))
dorycki
źródło
3
Spowoduje to 2 zapytania zamiast podzapytania.
grepsedawk