Vous voulez trouver des enregistrements sans enregistrements associés dans Rails

178

Prenons une simple association ...

class Person
   has_many :friends
end

class Friend
   belongs_to :person
end

Quelle est la manière la plus propre d'obtenir toutes les personnes qui n'ont AUCUN ami dans ARel et / ou meta_where?

Et puis qu'en est-il d'un has_many: through 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

Je ne veux vraiment pas utiliser counter_cache - et d'après ce que j'ai lu, cela ne fonctionne pas avec has_many: through

Je ne veux pas extraire tous les enregistrements person.friends et les parcourir en boucle dans Ruby - je veux avoir une requête / portée que je peux utiliser avec le gem meta_search

Le coût des performances des requêtes ne me dérange pas

Et plus vous vous éloignez du SQL réel, mieux c'est ...

craic.com
la source

Réponses:

110

C'est encore assez proche de SQL, mais cela devrait amener tout le monde sans amis dans le premier cas:

Person.where('id NOT IN (SELECT DISTINCT(person_id) FROM friends)')
Unixmonkey
la source
6
Imaginez que vous ayez 10000000 enregistrements dans la table des amis. Qu'en est-il des performances dans ce cas?
goodniceweb
@goodniceweb En fonction de votre fréquence de duplication, vous pouvez probablement supprimer le fichier DISTINCT. Sinon, je pense que vous voudriez normaliser les données et l'index dans ce cas. Je pourrais le faire en créant un friend_idshstore ou une colonne sérialisée. Alors vous pourriez direPerson.where(friend_ids: nil)
Unixmonkey
Si vous allez utiliser sql, il est probablement préférable d'utiliser not exists (select person_id from friends where person_id = person.id)(Ou peut people.id- être ou persons.id, en fonction de votre table.) Je ne sais pas quel est le plus rapide dans une situation particulière, mais dans le passé, cela a bien fonctionné pour moi lorsque je n'essayait pas d'utiliser ActiveRecord.
nroose le
442

Mieux:

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

Pour le hmt, c'est fondamentalement la même chose, vous comptez sur le fait qu'une personne sans amis n'aura pas non plus de contacts:

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

Mettre à jour

Vous avez une question à propos has_onedes commentaires, donc juste une mise à jour. L'astuce ici est qu'il includes()attend le nom de l'association mais whereattend le nom de la table. Pour un, has_onel'association sera généralement exprimée au singulier, de sorte que cela change, mais la where()partie reste telle qu'elle est. Donc, si Personseulement has_one :contactalors votre déclaration serait:

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

Mise à jour 2

Quelqu'un a demandé l'inverse, des amis sans personne. Comme je l'ai commenté ci-dessous, cela m'a fait réaliser que le dernier champ (ci-dessus: le :person_id) n'a pas à être lié au modèle que vous renvoyez, il doit simplement s'agir d'un champ dans la table de jointure. Ils vont tous l'être, nildonc ça peut être n'importe lequel d'entre eux. Cela conduit à une solution plus simple à ce qui précède:

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

Et puis changer cela pour renvoyer les amis sans personne devient encore plus simple, vous ne changez que la classe à l'avant:

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

Mise à jour 3 - Rails 5

Merci à @Anson pour l'excellente solution Rails 5 (donnez-lui quelques + 1 pour sa réponse ci-dessous), vous pouvez utiliser left_outer_joinspour éviter de charger l'association:

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

Je l'ai inclus ici pour que les gens le trouvent, mais il mérite les + 1 pour cela. Excellent ajout!

Mise à jour 4 - Rails 6.1

Merci à Tim Park d' avoir souligné que dans la prochaine version 6.1, vous pouvez le faire:

Person.where.missing(:contacts)

Merci aussi au post auquel il a lié.

smathy
la source
4
Vous pouvez l'intégrer dans une portée qui serait beaucoup plus propre.
Eytan
3
Bien meilleure réponse, je ne sais pas pourquoi l'autre est considéré comme accepté.
Tamik Soziev
5
Oui, mais en supposant que vous ayez un nom unique pour votre has_oneassociation, vous devez changer le nom de l'association dans l' includesappel. Donc, en supposant qu'il était à l' has_one :contactintérieur, Personvotre code seraitPerson.includes(:contact).where( :contacts => { :person_id => nil } )
smathy
3
Si vous utilisez un nom de table personnalisé dans votre modèle Friend ( self.table_name = "custom_friends_table_name"), utilisez Person.includes(:friends).where(:custom_friends_table_name => {:id => nil}).
Zek
5
@smathy Une belle mise à jour dans Rails 6.1 ajoute une missingméthode pour faire exactement cela !
Tim Park
172

smathy a une bonne réponse Rails 3.

Pour Rails 5 , vous pouvez utiliser left_outer_joinspour éviter de charger l'association.

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

Consultez la documentation de l' API . Il a été introduit dans la demande d'extraction # 12071 .

Anson
la source
Y a-t-il des inconvénients à cela? J'ai vérifié et il s'est chargé 0,1 ms plus rapidement que .includes
Qwertie
Ne pas charger l'association est un inconvénient si vous y accédez plus tard, mais un avantage si vous n'y accédez pas. Pour mes sites, un hit de 0,1 ms est assez négligeable, donc .includesun coût supplémentaire en temps de chargement ne serait pas quelque chose que je craindrais beaucoup d'optimiser. Votre cas d'utilisation peut être différent.
Anson
1
Et si vous n'avez pas encore Rails 5, vous pouvez le faire: Person.joins('LEFT JOIN contacts ON contacts.person_id = persons.id').where('contacts.id IS NULL')cela fonctionne aussi très bien comme lunette. Je fais cela tout le temps dans mes projets Rails.
Frank
3
Le gros avantage de cette méthode est l'économie de mémoire. Lorsque vous faites une includes, tous ces objets AR sont chargés en mémoire, ce qui peut être une mauvaise chose car les tables deviennent de plus en plus grandes. Si vous n'avez pas besoin d'accéder à l'enregistrement de contact, le left_outer_joinsne charge pas le contact en mémoire. La vitesse de requête SQL est la même, mais l'avantage global de l'application est beaucoup plus important.
chrismanderson
2
Ceci est vraiment bon! Merci! Maintenant, si les dieux des rails pourraient peut-être l'implémenter comme un simple Person.where(contacts: nil)ou Person.with(contact: contact)si l'utilisation de where empiète trop loin dans la `` properness '' - mais étant donné ce contact: est déjà analysé et identifié comme une association, il semble logique qu'arel puisse facilement déterminer ce qui est nécessaire ...
Justin Maxwell
14

Les personnes qui n'ont pas d'amis

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

Ou qui ont au moins un ami

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

Vous pouvez le faire avec Arel en configurant des étendues sur 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

Et puis, les personnes qui ont au moins un ami:

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

Les sans amis:

Person.includes(:friends).merge(Friend.to_nobody)
novembrekilo
la source
2
Je pense que vous pouvez aussi faire: Person.includes (: friends) .where (friends: {person: nil})
ReggieB
1
Remarque: la stratégie de fusion peut parfois donner un avertissement commeDEPRECATION 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

Les réponses de dmarkow et d'Unixmonkey m'apportent ce dont j'ai besoin - Merci!

J'ai essayé les deux dans ma vraie application et j'ai obtenu des horaires pour eux - Voici les deux champs d'application:

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

Ran this avec une vraie application - petite table avec ~ 700 enregistrements 'Person' - moyenne de 5 essais

L'approche d'Unixmonkey ( :without_friends_v1) 813ms / requête

Approche de dmarkow ( :without_friends_v2) 891ms / requête (~ 10% plus lent)

Mais ensuite, je me suis rendu compte que je n'avais pas besoin de l'appel pour DISTINCT()...rechercher des Personenregistrements avec NON Contacts- ils doivent donc simplement être NOT INla liste des contacts person_ids. J'ai donc essayé cette portée:

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

Cela donne le même résultat mais avec une moyenne de 425 ms / appel - presque la moitié du temps ...

Maintenant, vous pourriez avoir besoin du DISTINCTdans d'autres requêtes similaires - mais pour mon cas, cela semble fonctionner correctement.

Merci de votre aide

craic.com
la source
5

Malheureusement, vous êtes probablement à la recherche d'une solution impliquant SQL, mais vous pouvez la définir dans une portée et ensuite simplement utiliser cette portée:

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

Ensuite, pour les obtenir, vous pouvez simplement le faire Person.without_friends, et vous pouvez également l'enchaîner avec d'autres méthodes Arel:Person.without_friends.order("name").limit(10)

Dylan Markow
la source
1

Une sous-requête corrélée NOT EXISTS doit être rapide, en particulier lorsque le nombre de lignes et le ratio d'enregistrements enfants / parents augmentent.

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

Aussi, pour filtrer par un ami par exemple:

Friend.where.not(id: other_friend.friends.pluck(:id))
Dorian
la source
3
Cela entraînera 2 requêtes plutôt qu'une sous-requête.
grepsedawk