115 votes

Relation Many-to-many avec le même modèle dans rails ?

Comment créer une relation many-to-many avec le même modèle dans rails ?

Par exemple, chaque message est relié à de nombreux messages.

287voto

Shtééf Points 10444

Il existe plusieurs types de relations many-to-many ; vous devez vous poser les questions suivantes :

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

Cela laisse quatre possibilités différentes. Je vais les passer en revue ci-dessous.

Pour référence : la documentation Rails sur le sujet . Il y a une section intitulée "Many-to-many", et bien sûr la documentation sur les méthodes de la classe elle-même.

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

Il s'agit du code le plus compact.

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

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

Pour toute relation many-to-many, 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 donnerait posts_posts dans cette situation, j'ai donc décidé de prendre post_connections à la place.

Très important ici est :id => false pour omettre l'option par défaut id colonne. Rails veut cette colonne partout sauf sur les tables de jonction pour has_and_belongs_to_many . Il se plaindra bruyamment.

Enfin, remarquez que les noms des colonnes ne sont pas standard non plus (non post_id ), afin de prévenir les conflits.

Maintenant, dans votre modèle, vous devez simplement indiquer à Rails ces quelques éléments non standard. Cela ressemblera à ce qui suit :

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 d'exécution d'une session irb 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 à la posts créera des enregistrements dans la base de données post_connections le cas échéant.

Quelques points à noter :

  • Vous pouvez voir dans la session irb ci-dessus que l'association est unidirectionnelle, parce qu'après que a.posts = [b, c] la sortie de b.posts ne comprend pas le premier message.
  • Une autre chose que vous avez peut-être remarqué est qu'il n'y a pas de modèle PostConnection . Normalement, vous n'utilisez pas de modèles pour un has_and_belongs_to_many association. Pour cette raison, vous ne pourrez pas accéder à des champs supplémentaires.

Uni-directionnel, avec des champs supplémentaires

Bon, maintenant... Vous avez un utilisateur régulier qui a posté aujourd'hui sur votre site un message expliquant que les anguilles sont délicieuses. Ce parfait inconnu se rend sur votre site, s'inscrit et rédige un article virulent sur l'ineptie de l'utilisateur régulier. Après tout, les anguilles sont une espèce en voie de disparition !

Vous souhaitez donc indiquer clairement dans votre base de données que l'article B est une critique virulente de l'article A. Pour ce faire, vous voulez ajouter une balise category à l'association.

Ce dont nous avons besoin n'est plus un 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 jonction. C'est ce modèle supplémentaire qui nous permet d'ajouter des informations supplémentaires à l'association elle-même.

Voici un autre schéma, très similaire au précédent :

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, post_connections hace ont un id colonne. (Il y a pas de :id => false paramètre). Ceci est nécessaire, car il y aura un modèle ActiveRecord régulier pour accéder à la table.

Je vais commencer par le PostConnection parce que 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 de post_a o post_b que nous avons affaire à un poste ici. Nous devons le dire explicitement.

Maintenant, le Post modè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 le premier has_many l'association, nous disons au modèle de rejoindre post_connections sur posts.id = post_connections.post_a_id .

Avec la deuxième association, nous disons à Rails que nous pouvons atteindre les autres postes, ceux qui sont connectés à celui-ci, par le biais de notre première association. post_connections suivi par le post_b association de PostConnection .

Il y a juste une dernière chose manquant, et c'est que nous devons dire à Rails qu'une PostConnection dépend des postes auxquels il appartient. Si l'un ou les deux post_a_id y post_b_id étaient NULL alors ce lien ne nous dirait pas grand-chose, n'est-ce pas ? Voici comment nous faisons cela dans notre Post modè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 différentes ici :

  • El has_many :post_connections a un supplément :dependent paramètre. Avec la valeur :destroy nous indiquons à Rails qu'une fois que ce message aura disparu, il pourra détruire ces objets. Une autre valeur que vous pouvez utiliser ici est :delete_all ce qui est plus rapide, mais n'appellera pas les hooks de destruction si vous en utilisez.
  • Nous avons ajouté un has_many association pour le inverser les connexions aussi, celles qui nous ont liés à travers post_b_id . De cette façon, Rails peut aussi les détruire proprement. Notez que nous devons spécifier :class_name ici, parce que le nom de la classe du modèle ne peut plus être déduit à partir de :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 créer une PostConnection et en finir :

>> 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 aussi manipuler le post_connections y reverse_post_connections les associations ; il se reflétera clairement dans les posts association :

>> 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 bidirectionnelles en boucle

En normal has_and_belongs_to_many l'association est définie dans ambos modèles impliqués. Et l'association est bidirectionnelle.

Mais il n'y a qu'un seul modèle de poste 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_many et un modèle pour la table de jonction.

On peut le constater en accédant simplement aux associations à partir de 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, il faudrait trouver un moyen de faire en sorte que Rails OR les conditions ci-dessus avec post_a_id y post_b_id inversé, de sorte qu'il regarde dans les deux sens.

Malheureusement, la seule façon de le faire que je connaisse est plutôt compliquée. Vous devrez spécifier manuellement votre SQL en utilisant des options de has_and_belongs_to_many comme :finder_sql , :delete_sql etc. Ce n'est pas joli. (Je suis ouvert aux suggestions ici aussi. Quelqu'un ?)

0 votes

Merci pour vos commentaires ! :) J'ai fait quelques modifications supplémentaires. Plus précisément, le :foreign_key sur le has_many :through n'est pas nécessaire, et j'ai ajouté une explication sur la façon d'utiliser la très pratique :dependent pour has_many .

0 votes

@Shtééf même l'affectation en masse (update_attributes) ne fonctionnera pas dans le cas d'associations bidirectionnelles ex : postA.update_attributes({:post_b_ids =>[2,3,4]}) une idée ou une solution de contournement ?

0 votes

Très bonne réponse mon pote 5.times { met "+1" }

21voto

jbmilgrom Points 71

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

Associations bidirectionnelles en boucle

La relation suiveur-suivi chez Utilisateurs est un bon exemple d'une association en boucle bidirectionnelle. A Utilisateur peut en avoir plusieurs :

  • les adeptes en sa qualité d'adepte
  • en sa qualité de suiveur.

Voici comment le code pour utilisateur.rb pourrait ressembler :

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 éléments les plus importants à noter sont probablement les termes suivants :follower_follows y :followee_follows dans user.rb. Pour prendre l'exemple d'une association courante (non bouclée), une association de type Équipe peut avoir de nombreux : players par le biais de :contracts . Il en va de même pour un Joueur qui peuvent avoir plusieurs :teams par le biais de :contracts également (au cours d'une telle Joueur ). Mais dans ce cas, où il n'existe qu'un seul modèle nommé (c'est-à-dire une Utilisateur ), en nommant la relation through : de manière identique (par ex. through: :follow ou, comme cela a été fait ci-dessus dans l'exemple des postes, through: :post_connections ) entraînerait une collision de noms pour différents cas d'utilisation (ou points d'accès) de la table de jonction. :follower_follows y :followee_follows ont été créés pour éviter une telle collision de noms. Maintenant, un Utilisateur peut avoir de nombreux :followers par le biais de :follower_follows et beaucoup :followees par le biais de :followee_follows .

Pour déterminer un Utilisateur s :followees (sur un @user.followees à la base de données), Rails peut maintenant regarder chaque instance de class_name : "Follow" où cet utilisateur est le suiveur (c'est-à-dire foreign_key: :follower_id ) par : tel Utilisateur de :followee_follows. Pour déterminer un Utilisateur s :followers (sur un @user.followers à la base de données), Rails peut maintenant examiner chaque instance de class_name : "Follow" où un tel appel est effectué. Utilisateur est la personne à suivre (c'est à dire foreign_key: :followee_id ) par : tel Utilisateur de :follower_follows.

1 votes

Exactement ce dont j'avais besoin ! Merci ! (Je recommande également d'énumérer les migrations de la base de données ; j'ai dû glaner cette information dans la réponse acceptée).

6voto

jackquack Points 1246

Si quelqu'un venait ici pour essayer de trouver comment créer des relations d'amitié dans Rails, je le renverrais à ce que j'ai finalement décidé d'utiliser, à savoir copier ce qu'a fait 'Community Engine'.

Vous pouvez vous y 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"

1voto

Pour le mode bidirectionnel belongs_to_and_has_many Reportez-vous à l'excellente réponse déjà posté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_name pour pointer vers le bon modèle. Merci.

2 votes

Pourriez-vous montrer un exemple dans votre message ? J'ai essayé plusieurs méthodes comme vous l'avez suggéré, mais je n'arrive pas à le faire.

0voto

user2303277 Points 1

Si quelqu'un a eu des problèmes pour faire fonctionner l'excellente réponse, par exemple :

(L'objet ne supporte pas #inspect)
\=>

ou

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

La solution consiste alors à remplacer :PostConnection con "PostConnection" en substituant votre nom de classe bien sûr.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X