¿Relación de muchos a muchos con el mismo modelo en rieles?

107

¿Cómo puedo establecer una relación de varios a varios con el mismo modelo en rieles?

Por ejemplo, cada publicación está conectada a muchas publicaciones.

Víctor
fuente

Respuestas:

276

Hay varios tipos de relaciones de muchos a muchos; tienes que hacerte las siguientes preguntas:

  • ¿Quiero almacenar información adicional con la asociación? (Campos adicionales en la tabla de combinación).
  • ¿Las asociaciones deben ser implícitamente bidireccionales? (Si el poste A está conectado al poste B, entonces el poste B también está conectado al poste A.)

Eso deja cuatro posibilidades diferentes. Caminaré sobre estos a continuación.

Para referencia: la documentación de Rails sobre el tema . Hay una sección llamada "Muchos a muchos" y, por supuesto, la documentación sobre los métodos de clase en sí.

Escenario más simple, unidireccional, sin campos adicionales

Este es el código más compacto.

Comenzaré con este esquema básico para sus publicaciones:

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

Para cualquier relación de varios a varios, necesita una tabla de combinación. Aquí está el esquema para eso:

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

Por defecto, Rails llamará a esta tabla una combinación de los nombres de las dos tablas que estamos uniendo. Pero eso resultaría como posts_postsen esta situación, así que decidí tomar post_connectionsen su lugar.

Aquí es muy importante :id => falseomitir la idcolumna predeterminada . Rails quiere esa columna en todas partes excepto en tablas de unión para has_and_belongs_to_many. Se quejará en voz alta.

Por último, observe que los nombres de las columnas tampoco son estándar (no post_id) para evitar conflictos.

Ahora, en su modelo, simplemente necesita decirle a Rails sobre estas dos cosas no estándar. Se verá de la siguiente manera:

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

¡Y eso simplemente debería funcionar! Aquí hay un ejemplo de la sesión de irb ejecutada 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!">]

Descubrirá que la asignación a la postsasociación creará registros en la post_connectionstabla según corresponda.

Algunas cosas a tener en cuenta:

  • Puede ver en la sesión de irb anterior que la asociación es unidireccional, porque después a.posts = [b, c], la salida de b.postsno incluye la primera publicación.
  • Otra cosa que habrás notado es que no hay modelo PostConnection. Normalmente no usa modelos para una has_and_belongs_to_manyasociación. Por este motivo, no podrá acceder a ningún campo adicional.

Unidireccional, con campos adicionales

Bien, ahora ... Tienes un usuario habitual que hoy ha publicado una publicación en tu sitio sobre lo deliciosas que son las anguilas. Este completo extraño llega a su sitio, se registra y escribe una publicación de regaño sobre la ineptitud del usuario habitual. ¡Después de todo, las anguilas son una especie en peligro de extinción!

Entonces, le gustaría dejar en claro en su base de datos que la publicación B es una perorata de regaño sobre la publicación A. Para hacer eso, desea agregar un categorycampo a la asociación.

Lo que necesitamos ya no es una has_and_belongs_to_many, sino una combinación de has_many, belongs_to, has_many ..., :through => ...y un modelo adicional para la tabla de unión. Este modelo adicional es lo que nos da el poder de agregar información adicional a la propia asociación.

Aquí hay otro esquema, muy similar al anterior:

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

Observe cómo, en esta situación, post_connections tiene una idcolumna. (No hay ningún :id => false parámetro). Esto es obligatorio, porque habrá un modelo de ActiveRecord regular para acceder a la tabla.

Comenzaré con el PostConnectionmodelo, porque es muy simple:

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

Lo único que está sucediendo aquí es :class_name, lo cual es necesario, porque Rails no puede inferir post_ao post_bque estamos tratando con una publicación aquí. Tenemos que contarlo explícitamente.

Ahora el Postmodelo:

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

Con la primera has_manyasociación, nos dice el modelo para unirse post_connectionsa posts.id = post_connections.post_a_id.

Con la segunda asociación, le estamos diciendo a Rails que podemos llegar a los otros puestos, los conectados a este, a través de nuestra primera asociación post_connections, seguida de la post_basociación de PostConnection.

Solo falta una cosa más , y es que debemos decirle a Rails que a PostConnectiondepende de las publicaciones a las que pertenece. Si uno o ambos de post_a_idy post_b_idfueran NULL, entonces esa conexión no nos diría mucho, ¿verdad? Así es como lo hacemos en nuestro Postmodelo:

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

Además del ligero cambio en la sintaxis, dos cosas reales son diferentes aquí:

  • El has_many :post_connectionstiene un extra :dependentde parámetros. Con el valor :destroy, le decimos a Rails que, una vez desaparezca esta publicación, puede seguir adelante y destruir estos objetos. Un valor alternativo que puede usar aquí es :delete_all, que es más rápido, pero no llamará a ningún gancho de destrucción si los está usando.
  • También hemos agregado una has_manyasociación para las conexiones inversas , las que nos han vinculado post_b_id. De esta manera, Rails también puede destruirlos. Tenga en cuenta que tenemos que especificar :class_nameaquí, porque el nombre de la clase del modelo ya no se puede inferir :reverse_post_connections.

Con esto en su lugar, les traigo otra sesión de irb a través de 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

En lugar de crear la asociación y luego configurar la categoría por separado, también puede crear una PostConnection y terminar con ella:

>> 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!">]

Y también podemos manipular las asociaciones post_connectionsy reverse_post_connections; se reflejará claramente en la postsasociación:

>> 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
=> []

Asociaciones en bucle bidireccional

En has_and_belongs_to_manyasociaciones normales , la asociación se define en ambos modelos implicados. Y la asociación es bidireccional.

Pero solo hay un modelo Post en este caso. Y la asociación solo se especifica una vez. Precisamente por eso, en este caso específico, las asociaciones son unidireccionales.

Lo mismo es cierto para el método alternativo con has_manyy un modelo para la tabla de combinación.

Esto se ve mejor cuando simplemente se accede a las asociaciones desde irb y se observa el SQL que genera Rails en el archivo de registro. Encontrarás algo como lo siguiente:

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

Para hacer que la asociación sea bidireccional, tendríamos que encontrar una manera de hacer que Rails tenga ORlas condiciones anteriores con post_a_idy al post_b_idrevés, para que mire en ambas direcciones.

Desafortunadamente, la única forma de hacer esto que conozco es bastante hacky. Vas a tener que especificar manualmente el SQL usando opciones para has_and_belongs_to_manycomo :finder_sql, :delete_sql, etc. No es bonito. (Estoy abierto a sugerencias aquí también. ¿Alguien?)

Shtééf
fuente
¡Gracias por los buenos comentarios! :) He hecho algunas ediciones adicionales. Específicamente, :foreign_keyon the has_many :throughno es necesario, y agregué una explicación sobre cómo usar el :dependentparámetro muy útil para has_many.
Stéphan Kochen
@ Shtééf incluso la asignación masiva (update_attributes) no funcionará en el caso de asociaciones bidireccionales, por ejemplo: postA.update_attributes ({: post_b_ids => [2,3,4]}) ¿alguna idea o solución?
Lohith MV
Muy buena respuesta amigo 5 veces {pone "+1"}
Rahul
@ Shtééf Aprendí mucho de esta respuesta, ¡gracias! Intenté preguntar y responder su pregunta de asociación bidireccional aquí: stackoverflow.com/questions/25493368/…
jbmilgrom
17

Para responder a la pregunta planteada por Shteef:

Asociaciones en bucle bidireccional

La relación seguidor-seguidor entre los usuarios es un buen ejemplo de una asociación en bucle bidireccional. Un usuario puede tener muchos:

  • seguidores en su calidad de seguidor
  • seguidores en su calidad de seguidor.

Así es como se vería el código 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

Así es como se muestra el código de 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

Lo más importante a tener en cuenta son probablemente los términos :follower_follows y :followee_followsen user.rb. Para usar una asociación común y corriente (sin bucle) como ejemplo, un equipo puede tener muchas: a playerstravés :contracts. Esto no es diferente para un jugador , que puede tener muchos :teamsa través :contractstambién (en el transcurso de tales jugador profesional 's). Pero en este caso, donde solo existe un modelo con nombre (es decir, un Usuario ), nombrar la relación a través: de manera idéntica (p through: :follow. Ej. , O, como se hizo anteriormente en el ejemplo de publicaciones, through: :post_connections) resultaría en una colisión de nombres para diferentes casos de uso de ( o puntos de acceso a) la tabla de combinación. :follower_followsy:followee_followsfueron creados para evitar tal colisión de nombres. Ahora, un usuario puede tener muchas :followersa través :follower_followsy muchos :followeesa través :followee_follows.

Para determinar los seguidores de un Usuario (después de una @user.followeesllamada a la base de datos), Rails ahora puede mirar cada instancia de class_name: "Seguir" donde dicho Usuario es el seguidor (es decir foreign_key: :follower_id) a través de: such User 's: followee_follows. Para determinar un los seguidores de Usuario (tras una @user.followersllamada a la base de datos), Rails ahora puede mirar cada instancia de class_name: "Seguir" donde dicho Usuario es el siguiente (es decir foreign_key: :followee_id) a través de: such User 's: follower_follows.

jbmilgrom
fuente
1
¡Exactamente lo que necesitaba! ¡Gracias! (Recomiendo también enumerar las migraciones de la base de datos; tuve que obtener esa información de la respuesta aceptada)
Adam Denoon
6

Si alguien vino aquí para tratar de averiguar cómo crear relaciones de amistad en Rails, entonces lo recomendaría a lo que finalmente decidí usar, que es copiar lo que hizo 'Community Engine'.

Puede consultar:

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

y

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

para más información.

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
fuente
2

Inspirado por @ Stéphan Kochen, esto podría funcionar para asociaciones bidireccionales

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

entonces post.posts&& post.reversed_postsdebería funcionar, al menos funcionó para mí.

Alba Hoo
fuente
1

Para bidireccional belongs_to_and_has_many, consulte la excelente respuesta ya publicada y luego cree otra asociación con un nombre diferente, las claves externas invertidas y asegúrese de que ha class_nameconfigurado para apuntar al modelo correcto. Salud.

Zhenya Slabkovski
fuente
2
¿Podrías mostrar un ejemplo en tu publicación? He intentado varias formas, como sugirió, pero parece que no puedo clavarlo.
achabacha322
0

Si alguien tuvo problemas para lograr que la excelente respuesta funcionara, como:

(El objeto no admite #inspect)
=>

o

NoMethodError: método indefinido `split 'para: Misión: Símbolo

Entonces la solución es reemplazar :PostConnectioncon "PostConnection", por supuesto, sustituyendo su nombre de clase.

usuario2303277
fuente