Relation plusieurs-à-plusieurs avec le même modèle dans les rails?

107

Comment puis-je créer une relation plusieurs-à-plusieurs avec le même modèle dans les rails?

Par exemple, chaque publication est liée à de nombreuses publications.

Victor
la source

Réponses:

276

Il existe plusieurs types de relations plusieurs à plusieurs; vous devez vous poser les questions suivantes:

  • Est-ce que je souhaite stocker des informations supplémentaires avec l'association? (Champs supplémentaires dans la table de jointure.)
  • Les associations doivent-elles être implicitement bidirectionnelles? (Si le poste A est connecté au poste B, alors le poste B est également connecté au poste A.)

Cela laisse quatre possibilités différentes. Je vais les parcourir ci-dessous.

Pour référence: la documentation Rails sur le sujet . Il y a une section appelée «plusieurs-à-plusieurs», et bien sûr la documentation sur les méthodes de classe elles-mêmes.

Scénario le plus simple, unidirectionnel, pas de champs supplémentaires

C'est le code le plus compact.

Je vais commencer par ce schéma de base pour vos messages:

create_table "posts", :force => true do |t|
  t.string  "name", :null => false
end

Pour toute relation plusieurs-à-plusieurs, vous avez besoin d'une table de jointure. Voici le schéma pour cela:

create_table "post_connections", :force => true, :id => false do |t|
  t.integer "post_a_id", :null => false
  t.integer "post_b_id", :null => false
end

Par défaut, Rails appellera cette table une combinaison des noms des deux tables que nous joignons. Mais cela se passerait comme posts_postsdans cette situation, alors j'ai décidé de prendre à la post_connectionsplace.

Il est très important :id => falsed'omettre la idcolonne par défaut . Rails veut cette colonne partout sauf sur les tables de jointure pour has_and_belongs_to_many. Il se plaindra bruyamment.

Enfin, notez que les noms de colonnes ne sont pas non plus standard (non post_id) pour éviter les conflits.

Maintenant, dans votre modèle, vous devez simplement informer Rails de ces deux éléments non standard. Il ressemblera à ceci:

class Post < ActiveRecord::Base
  has_and_belongs_to_many(:posts,
    :join_table => "post_connections",
    :foreign_key => "post_a_id",
    :association_foreign_key => "post_b_id")
end

Et cela devrait tout simplement fonctionner! Voici un exemple de session irb exécutée script/console:

>> a = Post.create :name => 'First post!'
=> #<Post id: 1, name: "First post!">
>> b = Post.create :name => 'Second post?'
=> #<Post id: 2, name: "Second post?">
>> c = Post.create :name => 'Definitely the third post.'
=> #<Post id: 3, name: "Definitely the third post.">
>> a.posts = [b, c]
=> [#<Post id: 2, name: "Second post?">, #<Post id: 3, name: "Definitely the third post.">]
>> b.posts
=> []
>> b.posts = [a]
=> [#<Post id: 1, name: "First post!">]

Vous constaterez que l'affectation à l' postsassociation créera des enregistrements dans la post_connectionstable selon le cas.

Quelques points à noter:

  • Vous pouvez voir dans la session irb ci-dessus que l'association est unidirectionnelle, car après a.posts = [b, c], la sortie de b.postsn'inclut pas le premier message.
  • Une autre chose que vous avez peut-être remarqué est qu'il n'y a pas de modèle PostConnection. Vous n'utilisez normalement pas de modèles pour une has_and_belongs_to_manyassociation. Pour cette raison, vous ne pourrez accéder à aucun champ supplémentaire.

Unidirectionnel, avec champs supplémentaires

Bon, maintenant ... Vous avez un utilisateur régulier qui a publié aujourd'hui un message sur votre site sur la façon dont les anguilles sont délicieuses. Cet étranger total vient sur votre site, s'inscrit et écrit un message de réprimande sur l'ineptie de l'utilisateur régulier. Après tout, les anguilles sont une espèce en voie de disparition!

Donc, vous voudriez préciser dans votre base de données que le post B est un coup de gueule sur le post A. Pour ce faire, vous souhaitez ajouter un categorychamp à l'association.

Ce que nous avons besoin n'est plus has_and_belongs_to_many, mais une combinaison de has_many, belongs_to, has_many ..., :through => ...et un modèle supplémentaire pour la table de jointure. Ce modèle supplémentaire est ce qui nous donne le pouvoir d'ajouter des informations supplémentaires à l'association elle-même.

Voici un autre schéma, très similaire à celui ci-dessus:

create_table "posts", :force => true do |t|
  t.string  "name", :null => false
end

create_table "post_connections", :force => true do |t|
  t.integer "post_a_id", :null => false
  t.integer "post_b_id", :null => false
  t.string  "category"
end

Remarquez comment, dans cette situation, n'ont une colonne. (Il n'y a pas de paramètre.) Ceci est obligatoire, car il y aura un modèle ActiveRecord régulier pour accéder à la table.post_connections id :id => false

Je vais commencer par le PostConnectionmodèle, car c'est très simple:

class PostConnection < ActiveRecord::Base
  belongs_to :post_a, :class_name => :Post
  belongs_to :post_b, :class_name => :Post
end

La seule chose qui se passe ici est :class_name, ce qui est nécessaire, car Rails ne peut pas déduire post_aou post_bque nous avons affaire à un message ici. Nous devons le dire explicitement.

Maintenant le Postmodèle:

class Post < ActiveRecord::Base
  has_many :post_connections, :foreign_key => :post_a_id
  has_many :posts, :through => :post_connections, :source => :post_b
end

Avec la première has_manyassociation, nous disons au modèle de se joindre post_connectionsà posts.id = post_connections.post_a_id.

Avec la seconde association, nous disons à Rails que nous pouvons accéder aux autres postes, ceux liés à celui-ci, via notre première association post_connections, suivie de l' post_bassociation de PostConnection.

Il manque juste une dernière chose , c'est que nous devons dire à Rails que a PostConnectiondépend des postes auxquels il appartient. Si l'un ou les deux de post_a_idet l' post_b_idétaient NULL, alors cette connexion ne nous en dirait pas beaucoup, n'est-ce pas? Voici comment nous procédons dans notre Postmodèle:

class Post < ActiveRecord::Base
  has_many(:post_connections, :foreign_key => :post_a_id, :dependent => :destroy)
  has_many(:reverse_post_connections, :class_name => :PostConnection,
      :foreign_key => :post_b_id, :dependent => :destroy)

  has_many :posts, :through => :post_connections, :source => :post_b
end

Outre le léger changement de syntaxe, deux choses réelles sont ici différentes:

  • Le has_many :post_connectionsa un :dependentparamètre supplémentaire . Avec la valeur :destroy, nous disons à Rails qu'une fois que ce poste disparaît, il peut continuer et détruire ces objets. Une autre valeur que vous pouvez utiliser ici est :delete_all, qui est plus rapide, mais n'appellera aucun hook de destruction si vous les utilisez.
  • Nous avons également ajouté une has_manyassociation pour les connexions inverses , celles qui nous ont liés post_b_id. De cette façon, les rails peuvent également les détruire parfaitement. Notez que nous devons spécifier :class_nameici, car le nom de classe du modèle ne peut plus être déduit :reverse_post_connections.

Avec ceci en place, je vous apporte une autre session irb à travers script/console:

>> a = Post.create :name => 'Eels are delicious!'
=> #<Post id: 16, name: "Eels are delicious!">
>> b = Post.create :name => 'You insensitive cloth!'
=> #<Post id: 17, name: "You insensitive cloth!">
>> b.posts = [a]
=> [#<Post id: 16, name: "Eels are delicious!">]
>> b.post_connections
=> [#<PostConnection id: 3, post_a_id: 17, post_b_id: 16, category: nil>]
>> connection = b.post_connections[0]
=> #<PostConnection id: 3, post_a_id: 17, post_b_id: 16, category: nil>
>> connection.category = "scolding"
=> "scolding"
>> connection.save!
=> true

Au lieu de créer l'association puis de définir la catégorie séparément, vous pouvez également simplement créer une PostConnection et en finir avec:

>> b.posts = []
=> []
>> PostConnection.create(
?>   :post_a => b, :post_b => a,
?>   :category => "scolding"
>> )
=> #<PostConnection id: 5, post_a_id: 17, post_b_id: 16, category: "scolding">
>> b.posts(true)  # 'true' means force a reload
=> [#<Post id: 16, name: "Eels are delicious!">]

Et nous pouvons également manipuler les associations post_connectionset reverse_post_connections; il se reflétera parfaitement dans l' postsassociation:

>> a.reverse_post_connections
=> #<PostConnection id: 5, post_a_id: 17, post_b_id: 16, category: "scolding">
>> a.reverse_post_connections = []
=> []
>> b.posts(true)  # 'true' means force a reload
=> []

Associations en boucle bidirectionnelles

Dans les has_and_belongs_to_manyassociations normales , l'association est définie dans les deux modèles impliqués. Et l'association est bidirectionnelle.

Mais il n'y a qu'un seul modèle Post dans ce cas. Et l'association n'est spécifiée qu'une seule fois. C'est exactement pourquoi dans ce cas précis, les associations sont unidirectionnelles.

Il en va de même pour la méthode alternative avec has_manyet un modèle pour la table de jointure.

Ceci est mieux vu en accédant simplement aux associations depuis irb, et en regardant le SQL que Rails génère dans le fichier journal. Vous trouverez quelque chose comme ce qui suit:

SELECT * FROM "posts"
INNER JOIN "post_connections" ON "posts".id = "post_connections".post_b_id
WHERE ("post_connections".post_a_id = 1 )

Pour rendre l'association bidirectionnelle, nous devrons trouver un moyen de rendre Rails ORles conditions ci-dessus avec post_a_idet post_b_idinversées, de sorte qu'il regarde dans les deux sens.

Malheureusement, le seul moyen de le faire que je connaisse est plutôt piraté. Vous devez spécifier manuellement votre SQL en utilisant les options à has_and_belongs_to_manyce que :finder_sql, :delete_sqletc. Ce n'est pas assez. (Je suis également ouvert aux suggestions ici. Quelqu'un?)

Shtééf
la source
Merci pour les commentaires sympa! :) J'ai fait quelques modifications supplémentaires. Plus précisément, le :foreign_keysur le has_many :throughn'est pas nécessaire, et j'ai ajouté une explication sur la façon d'utiliser le :dependentparamètre très pratique pour has_many.
Stéphan Kochen
@ Shtééf même l'affectation de masse (update_attributes) ne fonctionnera pas en cas d'associations bidirectionnelles par exemple: postA.update_attributes ({: post_b_ids => [2,3,4]}) une idée ou des contournements?
Lohith MV
Très belle réponse mate 5. fois {met "+1"}
Rahul
@ Shtééf J'ai beaucoup appris de cette réponse, merci! J'ai essayé de poser et de répondre à votre question d'association bidirectionnelle ici: stackoverflow.com/questions/25493368
...
17

Pour répondre à la question posée par Shteef:

Associations en boucle bidirectionnelles

La relation suiveur-suivi entre les utilisateurs est un bon exemple d'association bidirectionnelle en boucle. Un utilisateur peut en avoir plusieurs:

  • suiveurs en sa qualité de suiveur
  • suivis en sa qualité de suiveur.

Voici à quoi pourrait ressembler le code de user.rb :

class User < ActiveRecord::Base
  # follower_follows "names" the Follow join table for accessing through the follower association
  has_many :follower_follows, foreign_key: :followee_id, class_name: "Follow" 
  # source: :follower matches with the belong_to :follower identification in the Follow model 
  has_many :followers, through: :follower_follows, source: :follower

  # followee_follows "names" the Follow join table for accessing through the followee association
  has_many :followee_follows, foreign_key: :follower_id, class_name: "Follow"    
  # source: :followee matches with the belong_to :followee identification in the Follow model   
  has_many :followees, through: :followee_follows, source: :followee
end

Voici comment le code pour follow.rb :

class Follow < ActiveRecord::Base
  belongs_to :follower, foreign_key: "follower_id", class_name: "User"
  belongs_to :followee, foreign_key: "followee_id", class_name: "User"
end

Les choses les plus importantes à noter sont probablement les termes :follower_followset :followee_followsdans user.rb. Pour utiliser une association run of the mill (sans boucle) comme exemple, une équipe peut avoir plusieurs: playersthrough :contracts. Ce n'est pas différent pour un joueur , qui peut avoir beaucoup à :teamstravers :contractsaussi bien (au cours de ce joueur carrière). Mais dans ce cas, où un seul modèle nommé existe (c'est-à-dire un utilisateur ), nommer la relation through: de manière identique (par exemple through: :follow, ou, comme cela a été fait ci-dessus dans l'exemple des articles, through: :post_connections) entraînerait une collision de noms pour différents cas d'utilisation de ( ou points d'accès dans) la table de jointure. :follower_followset:followee_followsont été créés pour éviter une telle collision de noms. Désormais, un utilisateur peut en avoir plusieurs à :followerstravers :follower_followset plusieurs à :followeestravers :followee_follows.

Pour déterminer les suivis d' un utilisateur (lors d'un @user.followeesappel à la base de données), Rails peut désormais examiner chaque instance de nom_classe: «Follow» où cet utilisateur est le suiveur (c'est-à-dire foreign_key: :follower_id) via: cet utilisateur : followee_follows. Pour déterminer les suiveurs d' un utilisateur (lors d'un @user.followersappel à la base de données), Rails peut désormais examiner chaque instance de nom_classe: «Follow» où cet utilisateur est le suivant (c'est-à-dire foreign_key: :followee_id) à travers: un tel utilisateur : follower_follows.

jbmilgrom
la source
1
Exactement ce dont j'avais besoin! Merci! (Je recommande également de répertorier les migrations de bases de données; j'ai dû glaner cette information dans la réponse acceptée)
Adam Denoon
6

Si quelqu'un venait ici pour essayer de découvrir comment créer des relations amicales dans Rails, je lui ferais référence à ce que j'ai finalement décidé d'utiliser, à savoir copier ce que 'Community Engine' a fait.

Vous pouvez vous référer à:

https://github.com/bborn/communityengine/blob/master/app/models/friendship.rb

et

https://github.com/bborn/communityengine/blob/master/app/models/user.rb

pour plus d'informations.

TL; DR

# user.rb
has_many :friendships, :foreign_key => "user_id", :dependent => :destroy
has_many :occurances_as_friend, :class_name => "Friendship", :foreign_key => "friend_id", :dependent => :destroy

..

# friendship.rb
belongs_to :user
belongs_to :friend, :class_name => "User", :foreign_key => "friend_id"
hrdwdmrbl
la source
2

Inspiré par @ Stéphan Kochen, cela pourrait fonctionner pour des associations bidirectionnelles

class Post < ActiveRecord::Base
  has_and_belongs_to_many(:posts,
    :join_table => "post_connections",
    :foreign_key => "post_a_id",
    :association_foreign_key => "post_b_id")

  has_and_belongs_to_many(:reversed_posts,
    :class_name => Post,
    :join_table => "post_connections",
    :foreign_key => "post_b_id",
    :association_foreign_key => "post_a_id")
 end

alors post.posts&& post.reversed_postsdevrait les deux travaux, au moins a fonctionné pour moi.

Alba Hoo
la source
1

Pour bidirectionnel belongs_to_and_has_many, reportez-vous à la bonne réponse déjà publiée, puis créez une autre association avec un nom différent, les clés étrangères inversées et assurez-vous que vous avez class_namedéfini pour pointer vers le bon modèle. À votre santé.

Zhenya Slabkovski
la source
2
Pourriez-vous montrer un exemple dans votre message? J'ai essayé plusieurs façons comme vous l'avez suggéré, mais je n'arrive pas à y parvenir.
achabacha322
0

Si quelqu'un avait des problèmes pour obtenir l'excellente réponse, comme:

(L'objet ne prend pas en charge #inspect)
=>

ou

NoMethodError: méthode non définie `split 'pour: Mission: Symbole

Ensuite, la solution est de remplacer :PostConnectionpar "PostConnection", en remplaçant bien sûr votre nom de classe.

user2303277
la source