Desideri trovare record senza record associati in Rails


178

Prendi in considerazione una semplice associazione ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Qual è il modo più pulito per ottenere tutte le persone che NON hanno amici in ARel e / o meta_where?

E poi che dire di un has_many: attraverso la versione

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

Non voglio davvero usare counter_cache - e da quello che ho letto non funziona con has_many: through

Non voglio estrarre tutti i record person.friends e sfogliarli in Ruby: voglio avere una query / ambito che posso usare con la gemma meta_search

Non mi dispiace il costo delle prestazioni delle query

E più lontano dall'attuale SQL, meglio è ...

Risposte:


110

Questo è ancora abbastanza vicino a SQL, ma nel primo caso dovrebbe portare tutti senza amici:

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

6
Immagina di avere 10000000 record nella tabella degli amici. Che dire delle prestazioni in quel caso?
goodniceweb,

@goodniceweb A seconda della frequenza duplicata, probabilmente è possibile eliminare DISTINCT. Altrimenti, penso che vorresti normalizzare i dati e l'indice in quel caso. Potrei farlo creando una friend_idscolonna hstore o serializzata. Quindi potresti direPerson.where(friend_ids: nil)
Unixmonkey il

Se hai intenzione di usare sql, probabilmente è meglio usare not exists (select person_id from friends where person_id = person.id)(o forse people.idopersons.id , a seconda di quale sia il tuo tavolo.) Non sono sicuro di quale sia il più veloce in una particolare situazione, ma in passato questo ha funzionato bene per me quando non cercavo di usare ActiveRecord.
chiuso il

442

Meglio:

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

Per l'hmt è sostanzialmente la stessa cosa, fai affidamento sul fatto che una persona senza amici non avrà contatti:

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

Aggiornare

Hai una domanda has_onenei commenti, quindi solo l'aggiornamento. Il trucco qui è che si includes()aspetta il nome dell'associazione ma ilwhere aspetta il nome della tabella. Per a has_onel'associazione sarà generalmente espressa al singolare, in modo che cambi, ma la where()parte rimane così com'è. Quindi, se Personsolo has_one :contactallora la tua affermazione sarebbe:

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

Aggiornamento 2

Qualcuno ha chiesto del contrario, amici senza gente. Come ho commentato di seguito, questo in realtà mi ha fatto capire che l'ultimo campo (sopra: il :person_id) in realtà non deve essere correlato al modello che stai restituendo, deve solo essere un campo nella tabella di join. Lo saranno tutti, nilquindi può essere uno di loro. Questo porta a una soluzione più semplice di quanto sopra:

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

E poi cambiando questo per restituire gli amici senza persone diventa ancora più semplice, cambi solo la classe in primo piano:

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

Aggiornamento 3 - Rotaie 5

Grazie a @Anson per l'eccellente soluzione Rails 5 (dategli alcuni +1 per la sua risposta di seguito), potete usare left_outer_joinsper evitare di caricare l'associazione:

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

L'ho incluso qui, così la gente lo troverà, ma per questo merita i +1. Grande aggiunta!

Aggiornamento 4 - Rotaie 6.1

Grazie a Tim Park per aver sottolineato che nella prossima 6.1 puoi farlo:

Person.where.missing(:contacts)

Grazie al post a cui è collegato anche lui.


4
Puoi incorporarlo in un ambito che sarebbe molto più pulito.
Eytan,

3
Risposta molto migliore, non sono sicuro del motivo per cui l'altro è valutato come accettato.
Tamik Soziev,

5
Sì sì, solo supponendo che tu abbia un nome singolare per la tua has_oneassociazione, devi cambiare il nome dell'associazione nella includeschiamata. Quindi supponendo che fosse has_one :contactall'interno, il Persontuo codice sarebbePerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy

3
Se stai usando un nome di tabella personalizzato nel tuo modello Friend ( self.table_name = "custom_friends_table_name"), quindi usa Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek,

5
@smathy Un bel aggiornamento in Rails 6.1 aggiunge un missingmetodo per fare esattamente questo !
Tim Park,

172

smathy ha una buona risposta su Rails 3.

Per Rails 5 , è possibile utilizzare left_outer_joinsper evitare il caricamento dell'associazione.

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

Dai un'occhiata ai documenti API . È stato introdotto nella richiesta pull n . 12071 .


Ci sono degli svantaggi in questo? Ho controllato e caricato 0.1 ms più veloce di
.includes

Non caricare l'associazione è un aspetto negativo se si accede effettivamente in un secondo momento, ma un vantaggio se non si accede ad esso. Per i miei siti, un hit di 0,1 ms è piuttosto trascurabile, quindi .includesil costo aggiuntivo nei tempi di caricamento non sarebbe qualcosa di cui mi preoccuperei molto per l'ottimizzazione. Il tuo caso d'uso potrebbe essere diverso.
Anson,

1
E se non hai ancora Rails 5, puoi farlo: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')funziona bene anche come ambito. Lo faccio sempre nei miei progetti Rails.
Frank,

3
Il grande vantaggio di questo metodo è il risparmio di memoria. Quando lo fai includes, tutti quegli oggetti AR vengono caricati in memoria, il che può essere un male quando le tabelle diventano sempre più grandi. Se non è necessario accedere al record del contatto, il contatto left_outer_joinsnon viene caricato in memoria. La velocità della richiesta SQL è la stessa, ma il vantaggio complessivo dell'app è molto maggiore.
Chrismanderson,

2
Questo è veramente buono! Grazie! Ora, se gli dei rotaie potessero forse implementarlo come un semplice Person.where(contacts: nil)o Person.with(contact: contact)se usassero dove invade troppo la "correttezza" - ma dato quel contatto: è già stato analizzato e identificato come un'associazione, sembra logico che Arel possa facilmente elaborare ciò che è richiesto ...
Justin Maxwell,

14

Persone che non hanno amici

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

O che hanno almeno un amico

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

Puoi farlo con Arel impostando gli ambiti su 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

E poi, le persone che hanno almeno un amico:

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

I senza amici:

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

2
Penso che puoi anche fare: Person.includes (: amici) .where (amici: {person: nil})
ReggieB

1
Nota: la strategia di unione a volte può generare un avvertimento simile aDEPRECATION 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

Entrambe le risposte di dmarkow e Unixmonkey mi danno ciò di cui ho bisogno - Grazie!

Ho provato entrambi nella mia app reale e ho avuto i tempi per loro - Ecco i due ambiti:

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

Ho corso con un'app reale - tavolino con ~ 700 record "Person" - media di 5 corse

Approccio di Unixmonkey ( :without_friends_v1) 813ms / query

Approccio di dmarkow ( :without_friends_v2) 891ms / query (~ 10% più lento)

Ma poi mi è venuto in mente che non ho bisogno della chiamata per DISTINCT()...cercare Personrecord con NO Contacts, quindi devono solo essere NOT INl'elenco dei contatti person_ids. Quindi ho provato questo ambito:

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

Questo ottiene lo stesso risultato ma con una media di 425 ms / chiamata - quasi la metà del tempo ...

Ora potresti aver bisogno di DISTINCT di altre domande simili, ma per il mio caso questo sembra funzionare bene.

Grazie per l'aiuto


5

Sfortunatamente, stai probabilmente esaminando una soluzione che coinvolge SQL, ma potresti impostarla in un ambito e quindi utilizzare tale ambito:

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

Quindi per ottenerli, puoi semplicemente farlo Person.without_friendse puoi anche concatenarlo con altri metodi Arel:Person.without_friends.order("name").limit(10)


1

Una sottoquery correlata NON ESISTE dovrebbe essere veloce, in particolare all'aumentare del numero di righe e del rapporto tra i record figlio e genitore.

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

1

Inoltre, per filtrare ad esempio un amico:

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

3
Ciò comporterà 2 query anziché una query secondaria.
Grepsedawk,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.