Möchten Sie Datensätze ohne zugehörige Datensätze in Rails finden?

177

Betrachten Sie eine einfache Assoziation ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Was ist der sauberste Weg, um alle Personen zu bekommen, die KEINE Freunde in ARel und / oder meta_where haben?

Und was ist dann mit einer has_many: durch Version

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

Ich möchte counter_cache wirklich nicht verwenden - und nach dem, was ich gelesen habe, funktioniert es nicht mit has_many: through

Ich möchte nicht alle person.friends-Datensätze abrufen und sie in Ruby durchlaufen - ich möchte eine Abfrage / einen Bereich haben, den ich mit dem meta_search-Juwel verwenden kann

Die Leistungskosten der Abfragen stören mich nicht

Und je weiter von SQL entfernt, desto besser ...

craic.com
quelle

Antworten:

110

Dies ist immer noch ziemlich nah an SQL, sollte aber im ersten Fall alle ohne Freunde erreichen:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
quelle
6
Stellen Sie sich vor, Sie haben 10000000 Datensätze in der Freundesliste. Was ist mit der Leistung in diesem Fall?
Goodniceweb
@goodniceweb Abhängig von Ihrer doppelten Häufigkeit können Sie die wahrscheinlich löschen DISTINCT. Andernfalls sollten Sie in diesem Fall die Daten und den Index normalisieren. Ich könnte das tun, indem ich einen friend_idshstore oder eine serialisierte Spalte erstelle. Dann könnte man sagenPerson.where(friend_ids: nil)
Unixmonkey
Wenn Sie SQL verwenden möchten, ist es wahrscheinlich besser, es zu verwenden not exists (select person_id from friends where person_id = person.id)(oder vielleicht people.idoder persons.id, je nachdem, was Ihre Tabelle ist). Ich bin mir nicht sicher, was in einer bestimmten Situation am schnellsten ist, aber in der Vergangenheit hat dies für mich gut funktioniert, wenn ich hat nicht versucht, ActiveRecord zu verwenden.
Nroose
441

Besser:

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

Für das hmt ist es im Grunde dasselbe, Sie verlassen sich auf die Tatsache, dass eine Person ohne Freunde auch keine Kontakte hat:

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

Aktualisieren

Ich habe eine Frage zu has_onein den Kommentaren, also nur aktualisieren. Der Trick dabei ist, includes()dass der Name der Zuordnung whereerwartet wird, aber der Name der Tabelle. Für a wird has_onedie Assoziation im Allgemeinen im Singular ausgedrückt, so dass sich dies ändert, aber der where()Teil bleibt wie er ist. Wenn also Personnur has_one :contactdann Ihre Aussage wäre:

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

Update 2

Jemand fragte nach dem Gegenteil, Freunde ohne Menschen. Wie ich unten kommentierte, wurde mir tatsächlich klar, dass das letzte Feld (oben: das :person_id) nicht unbedingt mit dem Modell verknüpft sein muss, das Sie zurückgeben, sondern nur ein Feld in der Verknüpfungstabelle. Sie werden alle nilso sein, dass es jeder von ihnen sein kann. Dies führt zu einer einfacheren Lösung für das oben Gesagte:

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

Und dann wird es noch einfacher, dies zu ändern, um die Freunde ohne Personen zurückzugeben. Sie ändern nur die Klasse an der Vorderseite:

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

Update 3 - Schienen 5

Dank @Anson für die hervorragende Rails 5-Lösung (geben Sie ihm einige +1 für seine Antwort unten) können Sie das left_outer_joinsLaden der Assoziation vermeiden:

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

Ich habe es hier aufgenommen, damit die Leute es finden, aber er verdient die +1 dafür. Tolle Ergänzung!

Update 4 - Rails 6.1

Vielen Dank an Tim Park für den Hinweis, dass Sie dies in der kommenden Version 6.1 tun können:

Person.where.missing(:contacts)

Dank des Beitrags, auf den er auch verlinkt hat.

smathy
quelle
4
Sie können dies in einen Bereich integrieren, der viel sauberer wäre.
Eytan
3
Weitaus bessere Antwort, nicht sicher, warum der andere als akzeptiert eingestuft wird.
Tamik Soziev
5
Ja, nur wenn Sie einen eindeutigen Namen für Ihre has_oneZuordnung haben, müssen Sie den Namen der Zuordnung im includesAnruf ändern . Angenommen, es wäre has_one :contactdrin, Persondann wäre Ihr CodePerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy
3
Wenn Sie in Ihrem Friend-Modell ( self.table_name = "custom_friends_table_name") einen benutzerdefinierten Tabellennamen verwenden , verwenden Sie Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek
5
@smathy Ein nettes Update in Rails 6.1 fügt eine missingMethode hinzu, um genau dies zu tun !
Tim Park
171

smathy hat eine gute Rails 3 Antwort.

Bei Rails 5 können Sie das left_outer_joinsLaden der Zuordnung vermeiden.

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

Schauen Sie sich die API-Dokumente an . Es wurde in der Pull-Anfrage Nr. 12071 eingeführt .

Anson
quelle
Gibt es irgendwelche Nachteile? Ich habe es überprüft und es wurde 0,1 ms schneller geladen als .includes
Qwertie
Das Nichtladen der Zuordnung ist ein Nachteil, wenn Sie später tatsächlich darauf zugreifen, aber ein Vorteil, wenn Sie nicht darauf zugreifen. Für meine Websites ist ein Treffer von 0,1 ms ziemlich vernachlässigbar, sodass .includesich mir keine Sorgen über die Optimierung machen würde, wenn zusätzliche Kosten für die Ladezeit anfallen würden. Ihr Anwendungsfall kann anders sein.
Anson
1
Und wenn Sie Rails 5 noch nicht haben, können Sie dies tun: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')Es funktioniert auch als Zielfernrohr einwandfrei. Ich mache das die ganze Zeit in meinen Rails-Projekten.
Frank
3
Der große Vorteil dieser Methode ist die Speichereinsparung. Wenn Sie eine ausführen includes, werden alle diese AR-Objekte in den Speicher geladen. Dies kann eine schlechte Sache sein, wenn Tabellen immer größer werden. Wenn Sie keinen Zugriff auf den Kontaktdatensatz benötigen, wird der Kontakt left_outer_joinsnicht in den Speicher geladen. Die SQL-Anforderungsgeschwindigkeit ist gleich, aber der allgemeine App-Vorteil ist viel größer.
Chrismanderson
2
Das ist sehr gut! Vielen Dank! Nun, wenn die Schienengötter es vielleicht einfach implementieren könnten Person.where(contacts: nil)oder Person.with(contact: contact)wenn sie verwenden, wo zu weit in die „Properness“ eingegriffen wird - aber angesichts dieses Kontakts, der bereits analysiert und als Assoziation identifiziert wird, scheint es logisch, dass arel leicht herausfinden könnte, was erforderlich ist ...
Justin Maxwell
14

Personen, die keine Freunde haben

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

Oder die mindestens einen Freund haben

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

Sie können dies mit Arel tun, indem Sie Bereiche einrichten 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

Und dann Personen, die mindestens einen Freund haben:

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

Der Freundlose:

Person.includes(:friends).merge(Friend.to_nobody)
novemberkilo
quelle
2
Ich denke, Sie können auch tun: Person.includes (: Freunde) .where (Freunde: {Person: nil})
ReggieB
1
Hinweis: Die Zusammenführungsstrategie kann manchmal eine Warnung wieDEPRECATION 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

Sowohl die Antworten von dmarkow als auch von Unixmonkey bringen mir das, was ich brauche - Danke!

Ich habe beide in meiner realen App ausprobiert und Timings für sie erhalten - Hier sind die beiden Bereiche:

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

Lief dies mit einer echten App - kleiner Tabelle mit ~ 700 'Person'-Datensätzen - durchschnittlich 5 Läufe

Unixmonkeys Ansatz ( :without_friends_v1) 813 ms / Abfrage

dmarkows Ansatz ( :without_friends_v2) 891 ms / Abfrage (~ 10% langsamer)

Aber dann kam mir der Gedanke, dass ich den Anruf nicht brauche, um nach Aufzeichnungen mit NEIN zu DISTINCT()...suchen - sie müssen also nur die Kontaktliste sein . Also habe ich diesen Bereich ausprobiert:PersonContactsNOT INperson_ids

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

Das ergibt das gleiche Ergebnis, aber mit durchschnittlich 425 ms / Anruf - fast die Hälfte der Zeit ...

Jetzt brauchen Sie vielleicht die DISTINCTin anderen ähnlichen Abfragen - aber für meinen Fall scheint dies gut zu funktionieren.

Danke für Ihre Hilfe

craic.com
quelle
5

Leider suchen Sie wahrscheinlich nach einer Lösung mit SQL, aber Sie können sie in einem Bereich festlegen und dann einfach diesen Bereich verwenden:

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

Um sie zu erhalten, können Sie dies einfach tun Person.without_friendsund dies auch mit anderen Arel-Methoden verketten:Person.without_friends.order("name").limit(10)

Dylan Markow
quelle
1

Eine NICHT EXISTIERTE korrelierte Unterabfrage sollte schnell sein, insbesondere wenn die Zeilenanzahl und das Verhältnis von untergeordneten zu übergeordneten Datensätzen zunehmen.

scope :without_friends, where("NOT EXISTS (SELECT null FROM contacts where contacts.person_id = people.id)")
David Aldridge
quelle
1

Zum Beispiel, um von einem Freund herauszufiltern:

Friend.where.not(id: other_friend.friends.pluck(:id))
Dorian
quelle
3
Dies führt zu 2 Abfragen anstelle einer Unterabfrage.
Grepsedawk