Ingin menemukan catatan tanpa catatan terkait di Rails

178

Pertimbangkan asosiasi sederhana ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Apa cara paling bersih untuk mendapatkan semua orang yang TIDAK memiliki teman di ARel dan / atau meta_where?

Lalu bagaimana dengan has_many: melalui versi

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

Saya benar-benar tidak ingin menggunakan counter_cache - dan saya dari apa yang saya baca tidak bekerja dengan has_many: through

Saya tidak ingin menarik semua catatan person.friends dan mengulanginya di Ruby - Saya ingin memiliki kueri / lingkup yang dapat saya gunakan dengan permata meta_search

Saya tidak keberatan dengan biaya kinerja dari kueri

Dan semakin jauh dari SQL aktual semakin baik ...

craic.com
sumber

Jawaban:

110

Ini masih cukup dekat dengan SQL, tetapi harus membuat semua orang tanpa teman dalam kasus pertama:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
sumber
6
Bayangkan saja Anda memiliki 10.000.000 catatan di tabel teman. Bagaimana dengan kinerja dalam hal itu?
goodniceweb
@goodniceweb Tergantung pada frekuensi duplikat Anda, Anda mungkin dapat menghapus DISTINCT. Kalau tidak, saya pikir Anda ingin menormalkan data dan indeks dalam kasus itu. Saya mungkin melakukannya dengan membuat friend_idskolom hstore atau serial. Maka Anda bisa mengatakanPerson.where(friend_ids: nil)
Unixmonkey
Jika Anda akan menggunakan sql, mungkin lebih baik untuk menggunakan not exists (select person_id from friends where person_id = person.id)(Atau mungkin people.idatau persons.id, tergantung pada apa meja Anda.) Tidak yakin apa yang tercepat dalam situasi tertentu, tetapi di masa lalu ini telah bekerja dengan baik untuk saya ketika saya tidak mencoba menggunakan ActiveRecord.
nroose
442

Lebih baik:

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

Untuk hmt pada dasarnya hal yang sama, Anda bergantung pada kenyataan bahwa seseorang tanpa teman juga tidak akan memiliki kontak:

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

Memperbarui

Punya pertanyaan tentang has_onedi komentar, jadi baru saja memperbarui. Kuncinya di sini adalah bahwa includes()mengharapkan nama asosiasi tetapi wheremengharapkan nama tabel. Untuk suatu has_oneasosiasi umumnya akan diekspresikan dalam bentuk tunggal, sehingga berubah, tetapi where()bagian tetap seperti itu. Jadi, jika Personhanya satu has_one :contactmaka pernyataan Anda adalah:

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

Perbarui 2

Seseorang bertanya tentang kebalikannya, teman tanpa orang. Seperti yang saya komentari di bawah ini, ini benar-benar membuat saya menyadari bahwa bidang terakhir (di atas: the :person_id) tidak benar-benar harus terkait dengan model yang Anda kembalikan, hanya harus berupa bidang di tabel bergabung. Mereka semua akan niljadi bisa salah satu dari mereka. Ini mengarah ke solusi yang lebih sederhana untuk yang di atas:

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

Dan kemudian beralih ini untuk mengembalikan teman tanpa orang menjadi lebih sederhana, Anda hanya mengubah kelas di depan:

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

Perbarui 3 - Rel 5

Terima kasih kepada @Anson untuk solusi Rails 5 yang sangat baik (beri dia +1 untuk jawabannya di bawah), Anda dapat menggunakan left_outer_joinsuntuk menghindari memuat asosiasi:

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

Saya sudah memasukkannya di sini sehingga orang-orang akan menemukannya, tetapi dia layak mendapatkan +1 untuk ini. Tambahan yang bagus!

Pembaruan 4 - Rel 6.1

Terima kasih kepada Tim Park karena telah menunjukkan bahwa pada 6.1 mendatang Anda dapat melakukan ini:

Person.where.missing(:contacts)

Berkat pos yang dia tautkan juga.

kotor
sumber
4
Anda dapat memasukkan ini ke dalam ruang lingkup yang akan jauh lebih bersih.
Eytan
3
Jawaban yang jauh lebih baik, tidak yakin mengapa yang lain dinilai diterima.
Tamik Soziev
5
Ya, hanya dengan anggapan Anda memiliki nama tunggal untuk has_oneasosiasi Anda, Anda perlu mengubah nama asosiasi dalam includespanggilan. Jadi dengan asumsi itu ada has_one :contactdi dalam Personmaka kode Anda akanPerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy
3
Jika Anda menggunakan nama tabel khusus dalam model Teman Anda ( self.table_name = "custom_friends_table_name"), maka gunakan Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek
5
@smathy Pembaruan yang bagus di Rails 6.1 menambahkan missingmetode untuk melakukan hal ini !
Tim Park
172

smathy memiliki jawaban Rails 3 yang bagus.

Untuk Rails 5 , Anda dapat menggunakan left_outer_joinsuntuk menghindari memuat asosiasi.

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

Lihat dokumen api . Itu diperkenalkan dalam permintaan tarik # 12071 .

Anson
sumber
Apakah ada kerugian untuk ini? Aku memeriksa dan dimuat 0,1 ms .includes maka lebih cepat
Qwertie
Tidak memuat asosiasi adalah kerugian jika Anda benar-benar mengaksesnya nanti, tetapi bermanfaat jika Anda tidak mengaksesnya. Untuk situs saya, hit 0,1 ms cukup dapat diabaikan, sehingga .includesbiaya tambahan dalam waktu buka tidak akan menjadi sesuatu yang saya khawatirkan tentang pengoptimalan. Kasing penggunaan Anda mungkin berbeda.
Anson
1
Dan jika Anda belum memiliki Rails 5, Anda dapat melakukan ini: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')Ini berfungsi dengan baik sebagai ruang lingkup juga. Saya melakukan ini sepanjang waktu di proyek Rails saya.
Frank
3
Keuntungan besar dari metode ini adalah penghematan memori. Ketika Anda melakukan includes, semua objek AR tersebut dimuat ke dalam memori, yang bisa menjadi hal yang buruk karena tabel semakin besar dan besar. Jika Anda tidak memerlukan akses ke catatan kontak, left_outer_joinsitu tidak memuat kontak ke dalam memori. Kecepatan permintaan SQL adalah sama, tetapi keseluruhan manfaat aplikasi jauh lebih besar.
chrismanderson
2
Ini sangat bagus! Terima kasih! Sekarang jika para dewa rel mungkin dapat mengimplementasikannya sebagai sederhana Person.where(contacts: nil)atau Person.with(contact: contact)jika menggunakan di mana melanggar terlalu jauh ke 'kelayakan' - tetapi mengingat kontak itu: sudah diurai dan diidentifikasi sebagai sebuah asosiasi, tampaknya logis bahwa arel dapat dengan mudah mengetahui apa yang diperlukan ...
Justin Maxwell
14

Orang yang tidak memiliki teman

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

Atau yang memiliki setidaknya satu teman

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

Anda dapat melakukan ini dengan Arel dengan menyiapkan cakupan 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

Dan kemudian, Orang yang memiliki setidaknya satu teman:

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

The friendless:

Person.includes(:friends).merge(Friend.to_nobody)
novemberkilo
sumber
2
Saya pikir Anda juga dapat melakukan: Person.includes (: teman) .di mana saja (teman: {orang: nil})
ReggieB
1
Catatan: Strategi penggabungan terkadang dapat menghasilkan peringatan sepertiDEPRECATION 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

Baik jawaban dari dmarkow dan Unixmonkey memberi saya apa yang saya butuhkan - Terima kasih!

Saya mencoba keduanya di aplikasi asli saya dan mendapatkan timing untuk mereka - Berikut adalah dua cakupannya:

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

Jalankan ini dengan aplikasi nyata - meja kecil dengan ~ 700 catatan 'Person' - rata-rata 5 berjalan

Pendekatan Unixmonkey ( :without_friends_v1) 813ms / permintaan

pendekatan dmarkow ( :without_friends_v2) 891ms / permintaan (~ 10% lebih lambat)

Tetapi kemudian terlintas dalam benak saya bahwa saya tidak memerlukan panggilan untuk DISTINCT()...mencari Personcatatan dengan TIDAK Contacts- jadi mereka hanya perlu menjadi NOT INdaftar kontak person_ids. Jadi saya mencoba cakupan ini:

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

Itu mendapatkan hasil yang sama tetapi dengan rata-rata 425 ms / panggilan - hampir separuh waktu ...

Sekarang Anda mungkin perlu DISTINCTdalam pertanyaan serupa lainnya - tetapi untuk kasus saya ini sepertinya berfungsi dengan baik.

Terima kasih atas bantuan Anda

craic.com
sumber
5

Sayangnya, Anda mungkin melihat solusi yang melibatkan SQL, tetapi Anda bisa mengaturnya dalam lingkup dan kemudian gunakan lingkup itu:

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

Kemudian untuk mendapatkannya, Anda bisa melakukannya Person.without_friends, dan Anda juga bisa mengaitkannya dengan metode Arel lainnya:Person.without_friends.order("name").limit(10)

Dylan Markow
sumber
1

A TIDAK ADA subquery berkorelasi harus cepat, terutama karena jumlah baris dan rasio catatan anak ke orang tua meningkat.

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

Juga, untuk memfilter oleh satu teman misalnya:

Friend.where.not(id: other_friend.friends.pluck(:id))
Dorian
sumber
3
Ini akan menghasilkan 2 pertanyaan daripada subquery.
grepsedawk