Muốn tìm bản ghi không có bản ghi liên quan trong Rails


178

Hãy xem xét một hiệp hội đơn giản ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Cách sạch nhất để có được tất cả những người KHÔNG có bạn bè trong ARel và / hoặc meta_where là gì?

Và sau đó là gì về has_many: thông qua phiên bản

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

Tôi thực sự không muốn sử dụng counter_cache - và tôi từ những gì tôi đã đọc nó không hoạt động với has_many: thông qua

Tôi không muốn lấy tất cả các bản ghi person.friends và lặp qua chúng trong Ruby - Tôi muốn có một truy vấn / phạm vi mà tôi có thể sử dụng với đá quý meta_search

Tôi không quan tâm đến chi phí hiệu năng của các truy vấn

Và càng xa SQL thực tế thì càng tốt ...

Câu trả lời:


110

Điều này vẫn khá gần với SQL, nhưng nó sẽ khiến mọi người không có bạn bè trong trường hợp đầu tiên:

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

6
Chỉ cần tưởng tượng bạn có 10000000 hồ sơ trong bảng bạn bè. Hiệu suất trong trường hợp đó là gì?
goodniceweb

@goodniceweb Tùy thuộc vào tần số trùng lặp của bạn, bạn có thể bỏ qua DISTINCT. Mặt khác, tôi nghĩ rằng bạn muốn bình thường hóa dữ liệu và chỉ mục trong trường hợp đó. Tôi có thể làm điều đó bằng cách tạo một friend_idshstore hoặc cột nối tiếp. Sau đó, bạn có thể nóiPerson.where(friend_ids: nil)
Unixmonkey

Nếu bạn sẽ sử dụng sql, có lẽ tốt hơn để sử dụng not exists (select person_id from friends where person_id = person.id)(Hoặc có thể people.idhoặc persons.id, tùy thuộc vào bảng của bạn là gì.) Không chắc chắn nhanh nhất là gì trong một tình huống cụ thể, nhưng trong quá khứ, điều này đã hoạt động tốt với tôi khi tôi đã không cố gắng sử dụng ActiveRecord.
nroose

442

Tốt hơn:

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

Đối với hmt về cơ bản là điều tương tự, bạn dựa vào thực tế là một người không có bạn bè cũng sẽ không có liên hệ:

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

Cập nhật

Có một câu hỏi về has_onecác ý kiến, vì vậy chỉ cần cập nhật. Thủ thuật ở đây là includes()kỳ vọng tên của hiệp hội nhưng wherekỳ vọng tên của bảng. Đối với một has_onehiệp hội nói chung sẽ được thể hiện trong số ít, do đó thay đổi, nhưng where()phần vẫn như cũ. Vì vậy, nếu Personchỉ has_one :contactthì tuyên bố của bạn sẽ là:

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

Cập nhật 2

Có người hỏi về nghịch đảo, bạn bè không có người. Như tôi đã nhận xét bên dưới, điều này thực sự khiến tôi nhận ra rằng trường cuối cùng (ở trên :person_id:) không thực sự phải liên quan đến mô hình bạn đang trả về, nó chỉ phải là một trường trong bảng tham gia. Tất cả đều nilnhư vậy nên nó có thể là bất kỳ ai trong số họ. Điều này dẫn đến một giải pháp đơn giản hơn cho các điều trên:

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

Và sau đó chuyển đổi điều này để trả lại những người bạn không có người trở nên đơn giản hơn, bạn chỉ thay đổi lớp ở phía trước:

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

Cập nhật 3 - Đường ray 5

Cảm ơn @Anson về giải pháp Rails 5 tuyệt vời (cung cấp cho anh ấy một số +1 cho câu trả lời của anh ấy bên dưới), bạn có thể sử dụng left_outer_joinsđể tránh tải liên kết:

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

Tôi đã đưa nó vào đây để mọi người sẽ tìm thấy nó, nhưng anh ấy xứng đáng được +1 cho điều này. Bổ sung tuyệt vời!

Cập nhật 4 - Đường ray 6.1

Cảm ơn Tim Park đã chỉ ra rằng trong bản 6.1 sắp tới, bạn có thể làm điều này:

Person.where.missing(:contacts)

Nhờ bài đăng mà anh liên kết quá.


4
Bạn có thể kết hợp điều này vào một phạm vi sẽ sạch hơn nhiều.
Eytan

3
Câu trả lời tốt hơn nhiều, không chắc tại sao một người khác được đánh giá là chấp nhận.
Tamik Soziev

5
Đúng vậy, chỉ cần giả sử bạn có một tên riêng cho has_oneliên kết của mình, bạn cần thay đổi tên của hiệp hội trong includescuộc gọi. Vì vậy, giả sử nó là has_one :contactbên trong Personsau đó mã của bạn sẽ làPerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy

3
Nếu bạn đang sử dụng tên bảng tùy chỉnh trong mô hình Bạn bè ( self.table_name = "custom_friends_table_name"), thì hãy sử dụng Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek

5
@smathy Một bản cập nhật đẹp trong Rails 6.1 thêm một missingphương pháp để thực hiện chính xác điều này !
Công viên Tim

172

smathy có một câu trả lời Rails 3 tốt.

Đối với Rails 5 , bạn có thể sử dụng left_outer_joinsđể tránh tải liên kết.

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

Kiểm tra các tài liệu api . Nó được giới thiệu trong yêu cầu kéo # 12071 .


Có bất kỳ nhược điểm này? Tôi đã kiểm tra và nó tải nhanh hơn 0,1 ms sau đó
Bao gồm

Không tải hiệp hội là một nhược điểm nếu bạn thực sự truy cập nó sau này, nhưng sẽ là một lợi ích nếu bạn không truy cập nó. Đối với các trang web của tôi, một lần truy cập 0,1ms là không đáng kể, do đó, .includeschi phí thêm trong thời gian tải sẽ không phải là điều tôi lo lắng nhiều về việc tối ưu hóa. Trường hợp sử dụng của bạn có thể khác nhau.
Anson

1
Và nếu bạn chưa có Rails 5, bạn có thể làm điều này: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')Nó cũng hoạt động tốt như một phạm vi. Tôi làm điều này mọi lúc trong các dự án Rails của tôi.
Frank

3
Ưu điểm lớn của phương pháp này là tiết kiệm bộ nhớ. Khi bạn thực hiện includes, tất cả các đối tượng AR đó được tải vào bộ nhớ, đây có thể là một điều tồi tệ khi các bảng ngày càng lớn hơn. Nếu bạn không cần truy cập vào bản ghi liên hệ, left_outer_joinsthì không tải liên lạc vào bộ nhớ. Tốc độ yêu cầu SQL là như nhau, nhưng lợi ích chung của ứng dụng lớn hơn nhiều.
chrismanderson

2
Cái này thật sự rất tốt! Cảm ơn! Bây giờ nếu các vị thần đường ray có thể thực hiện nó đơn giản Person.where(contacts: nil)hoặc Person.with(contact: contact)nếu sử dụng nơi xâm lấn quá xa vào 'tính đúng đắn' - nhưng với liên hệ đó: đã được phân tích cú pháp và được xác định là một hiệp hội, có vẻ hợp lý rằng arel có thể dễ dàng tìm ra những gì cần thiết ...
Justin Maxwell

14

Người không có bạn bè

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

Hoặc có ít nhất một người bạn

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

Bạn có thể làm điều này với Arel bằng cách thiết lập phạm vi trên 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

Và sau đó, những người có ít nhất một người bạn:

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

Người không thân thiện:

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

2
Tôi nghĩ bạn cũng có thể làm: Person.includes (: bạn bè) .where (bạn bè: {người: nil})
ReggieB

1
Lưu ý: Chiến lược hợp nhất đôi khi có thể mang lại một cảnh báo như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 3/03/2015

12

Cả hai câu trả lời từ dmarkow và Unixmonkey đều cho tôi những gì tôi cần - Cảm ơn bạn!

Tôi đã thử cả hai trong ứng dụng thực sự của mình và có thời gian cho chúng - Đây là hai phạm vi:

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

Chạy ứng dụng này với một ứng dụng thực tế - bảng nhỏ với ~ 700 'hồ sơ' Người - trung bình 5 lần chạy

Cách tiếp cận của Unixmonkey ( :without_friends_v1) 813ms / truy vấn

cách tiếp cận của dmarkow ( :without_friends_v2) 891ms / truy vấn (chậm hơn 10%)

Nhưng sau đó tôi nhận ra rằng tôi không cần cuộc gọi tới DISTINCT()...Tôi đang tìm Personhồ sơ mà KHÔNG Contacts- vì vậy họ chỉ cần là NOT INdanh sách liên lạc person_ids. Vì vậy, tôi đã thử phạm vi này:

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

Điều đó nhận được kết quả tương tự nhưng với trung bình 425 ms / cuộc gọi - gần một nửa thời gian ...

Bây giờ bạn có thể cần DISTINCTcác truy vấn tương tự khác - nhưng đối với trường hợp của tôi, điều này dường như hoạt động tốt.

Cảm ơn bạn đã giúp đỡ


5

Thật không may, có lẽ bạn đang xem một giải pháp liên quan đến SQL, nhưng bạn có thể đặt nó trong một phạm vi và sau đó chỉ cần sử dụng phạm vi đó:

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

Sau đó, để có được chúng, bạn chỉ cần làm Person.without_friendsvà bạn cũng có thể xâu chuỗi nó với các phương thức Arel khác:Person.without_friends.order("name").limit(10)


1

Một truy vấn phụ KHÔNG tương quan phải nhanh, đặc biệt khi số lượng hàng và tỷ lệ của hồ sơ con so với cha mẹ tăng lên.

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

1

Ngoài ra, để lọc ra bởi một người bạn chẳng hạn:

Friend.where.not(id: other_friend.friends.pluck(:id))

3
Điều này sẽ dẫn đến 2 truy vấn chứ không phải là truy vấn con.
grepsedawk
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.