ต้องการค้นหาระเบียนที่ไม่มีระเบียนที่เกี่ยวข้องใน 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: ถึง

ฉันไม่ต้องการดึงระเบียนบุคคลทั้งหมดมารวมกันและวนซ้ำใน Ruby - ฉันต้องการมีแบบสอบถาม / ขอบเขตที่ฉันสามารถใช้กับ meta_search gem

ฉันไม่คำนึงถึงต้นทุนด้านประสิทธิภาพของข้อความค้นหา

และยิ่งห่างจาก SQL จริงยิ่งดี ...

คำตอบ:


110

นี่ยังค่อนข้างใกล้เคียงกับ SQL แต่ควรให้ทุกคนไม่มีเพื่อนในกรณีแรก:

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

6
แค่คิดว่าคุณมี 10,000,000 บันทึกในตารางเพื่อน สิ่งที่เกี่ยวกับประสิทธิภาพในกรณีนั้น
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)

ขอบคุณโพสต์ที่เขาเชื่อมโยงด้วย


4
คุณสามารถรวมสิ่งนี้ไว้ในขอบเขตที่จะสะอาดกว่ามาก
Eytan

3
คำตอบที่ดีกว่าไม่แน่ใจว่าทำไมอีกคนหนึ่งให้คะแนนว่าเป็นที่ยอมรับ
Tamik Soziev

5
ใช่เพียงแค่สมมติว่าคุณมีชื่อเอกสิทธิ์สำหรับการhas_oneเชื่อมโยงของคุณคุณจำเป็นต้องเปลี่ยนชื่อของการเชื่อมโยงในการincludesโทร ดังนั้นถ้าสมมุติว่ามันอยู่has_one :contactข้างในPersonโค้ดของคุณก็คือPerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy

3
หากคุณกำลังใช้ชื่อตารางที่กำหนดเองในรูปแบบเพื่อนของคุณ ( self.table_name = "custom_friends_table_name") Person.includes(:friends).where(:custom_friends_table_name => {:id => nil})จากนั้นใช้
Zek

5
@smathy การปรับปรุงที่ดีใน Rails 6.1 เพิ่มmissingวิธีการทำสิ่งนี้ !
Tim Park

172

smathy ตอบ Rails 3 ได้ดี

สำหรับ Rails 5คุณสามารถใช้left_outer_joinsเพื่อหลีกเลี่ยงการโหลดการเชื่อมโยง

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

ตรวจสอบเอกสาร API มันถูกนำมาใช้ในการร้องขอดึง# 12071


มีข้อเสียใด ๆ ในเรื่องนี้หรือไม่? ฉันตรวจสอบแล้วและโหลดเร็วขึ้น 0.1 ms. รวมอยู่ด้วย
Qwertie

การไม่โหลดการเชื่อมโยงเป็นข้อเสียถ้าคุณเข้าใช้งานได้ในภายหลัง แต่เป็นประโยชน์ถ้าคุณไม่เข้าถึง สำหรับเว็บไซต์ของฉันการเข้าชม 0.1 มิลลิวินาทีนั้นค่อนข้างเล็กน้อยดังนั้น.includesค่าใช้จ่ายในการโหลดเพิ่มจึงไม่ใช่สิ่งที่ฉันจะต้องกังวลเกี่ยวกับการปรับให้เหมาะสม กรณีการใช้งานของคุณอาจแตกต่างกัน
Anson

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)ถ้าใช้ในกรณีที่การรุกล้ำเข้าไปใน 'ความเหมาะสม' - แต่เมื่อได้รับการติดต่อ: กำลังถูกแจงและระบุว่าเป็นสมาคมแล้วดูเหมือนว่าตรรกะ ...
Justin Maxwell

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 (: friends) .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
genkilabs

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 'Person' - เฉลี่ย 5 รอบ

วิธีการของ Unixmonkey ( :without_friends_v1) 813ms / แบบสอบถาม

แนวทางของ dmarkow ( :without_friends_v2) 891ms / ข้อความค้นหา (ช้าลง 10% ~)

แต่แล้วมันเกิดขึ้นกับผมว่าผมไม่จำเป็นต้องเรียกร้องให้DISTINCT()...ฉันกำลังมองหาPersonระเบียนที่มี NO Contacts- ดังนั้นพวกเขาก็จะต้องมีรายชื่อของผู้ติดต่อNOT IN person_idsดังนั้นฉันจึงลองใช้ขอบเขตนี้:

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

ที่ได้รับผลลัพธ์เดียวกัน แต่มีค่าเฉลี่ย 425 ms / โทร - เกือบครึ่งเวลา ...

ตอนนี้คุณอาจต้องใช้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
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.