Relacionamento muitos para muitos com o mesmo modelo em trilhos?

107

Como posso fazer um relacionamento muitos-para-muitos com o mesmo modelo em trilhos?

Por exemplo, cada postagem está conectada a várias postagens.

Vencedor
fonte

Respostas:

276

Existem vários tipos de relacionamentos muitos para muitos; você tem que se perguntar as seguintes perguntas:

  • Desejo armazenar informações adicionais com a associação? (Campos adicionais na tabela de junção.)
  • As associações precisam ser implicitamente bidirecionais? (Se a postagem A estiver conectada à postagem B, a postagem B também será conectada à postagem A.)

Isso deixa quatro possibilidades diferentes. Vou examinar isso abaixo.

Para referência: a documentação do Rails sobre o assunto . Há uma seção chamada “Muitos para muitos” e, claro, a documentação dos próprios métodos de classe.

Cenário mais simples, unidirecional, sem campos adicionais

Este é o código mais compacto.

Vou começar com este esquema básico para suas postagens:

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

Para qualquer relacionamento muitos para muitos, você precisa de uma tabela de junção. Aqui está o esquema para isso:

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 padrão, o Rails chamará esta tabela de uma combinação dos nomes das duas tabelas que estamos unindo. Mas isso seria como posts_postsnesta situação, então decidi tomar em post_connectionsvez disso.

Muito importante aqui é :id => falseomitir a idcoluna padrão . Rails quer essa coluna em todos os lugares, exceto em tabelas de junção para has_and_belongs_to_many. Ele vai reclamar em voz alta.

Finalmente, observe que os nomes das colunas também não são padronizados (não post_id), para evitar conflitos.

Agora, em seu modelo, você simplesmente precisa dizer ao Rails sobre algumas coisas fora do padrão. Será o seguinte:

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

E isso deve simplesmente funcionar! Aqui está um exemplo de sessão irb executada 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!">]

Você descobrirá que atribuir à postsassociação criará registros na post_connectionstabela conforme apropriado.

Algumas coisas a serem observadas:

  • Você pode ver na sessão irb acima que a associação é unidirecional, porque depois a.posts = [b, c], a saída de b.postsnão inclui o primeiro post.
  • Outra coisa que você deve ter notado é que não existe um modelo PostConnection. Você normalmente não usa modelos para uma has_and_belongs_to_manyassociação. Por esse motivo, você não poderá acessar nenhum campo adicional.

Unidirecional, com campos adicionais

Certo, agora ... Você tem um usuário regular que postou hoje no seu site como as enguias são deliciosas. Esse estranho chega ao seu site, se inscreve e escreve uma postagem de repreensão sobre a inépcia do usuário regular. Afinal, as enguias são uma espécie em extinção!

Portanto, você gostaria de deixar claro em seu banco de dados que a postagem B é um discurso de censura na postagem A. Para fazer isso, você deseja adicionar um categorycampo à associação.

O que precisamos não é mais um é has_and_belongs_to_many, mas uma combinação de has_many, belongs_to, has_many ..., :through => ...e um modelo extra para a tabela de junção. Esse modelo extra é o que nos dá o poder de adicionar informações adicionais à própria associação.

Aqui está outro esquema, muito semelhante ao 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 como, nesta situação, post_connections não tem uma idcoluna. (Não :id => false parâmetro.) Isso é necessário, porque haverá um modelo ActiveRecord regular para acessar a tabela.

Vou começar com o PostConnectionmodelo, porque é muito simples:

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

A única coisa acontecendo aqui é :class_name, o que é necessário, porque o Rails não pode inferir post_aou post_bque estamos lidando com um Post aqui. Temos que dizer isso explicitamente.

Agora o Postmodelo:

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

Com a primeira has_manyassociação, dizemos o modelo para se juntar post_connectionsem posts.id = post_connections.post_a_id.

Com a segunda associação, estamos dizendo ao Rails que podemos alcançar os outros postos, os que estão conectados a este, por meio de nossa primeira associação post_connections, seguida da post_bassociação de PostConnection.

Só falta mais uma coisa , e é que precisamos dizer ao Rails que a PostConnectiondepende dos posts aos quais pertence. Se um ou ambos post_a_ide post_b_idfossem NULL, essa conexão não nos diria muito, não é? Veja como fazemos isso em nosso 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

Além da ligeira mudança na sintaxe, duas coisas reais são diferentes aqui:

  • O has_many :post_connectionstem um :dependentparâmetro extra . Com o valor :destroy, dizemos ao Rails que, uma vez que esta postagem desapareça, ele pode seguir em frente e destruir esses objetos. Um valor alternativo que você pode usar aqui é :delete_all, que é mais rápido, mas não chamará nenhum hook de destruição se você os estiver usando.
  • Adicionamos uma has_manyassociação para as conexões reversas também, aquelas que nos ligaram post_b_id. Dessa forma, o Rails pode destruí-los perfeitamente. Observe que temos que especificar :class_nameaqui, porque o nome da classe do modelo não pode mais ser inferido :reverse_post_connections.

Com isso no lugar, trago a você outra sessão de irb por meio 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

Em vez de criar a associação e definir a categoria separadamente, você também pode simplesmente criar um PostConnection e pronto:

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

E também podemos manipular as associações post_connectionse reverse_post_connections; isso refletirá perfeitamente na postsassociação:

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

Associações em loop bidirecionais

Em has_and_belongs_to_manyassociações normais , a associação é definida em ambos os modelos envolvidos. E a associação é bidirecional.

Mas há apenas um modelo de Post neste caso. E a associação é especificada apenas uma vez. É exatamente por isso que, neste caso específico, as associações são unidirecionais.

O mesmo é verdadeiro para o método alternativo com has_manye um modelo para a tabela de junção.

Isso é melhor visto simplesmente acessando as associações do irb, e olhando o SQL que o Rails gera no arquivo de log. Você encontrará algo como o seguinte:

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

Para tornar a associação bidirecional, teríamos que encontrar uma maneira de tornar Rails ORas condições acima com post_a_ide post_b_idinvertidas, para que olhe nas duas direções.

Infelizmente, a única maneira de fazer isso que eu conheço é bastante hacky. Você terá que especificar manualmente o SQL usando opções para has_and_belongs_to_manytais como :finder_sql, :delete_sql, etc. Não é bonito. (Também estou aberto a sugestões aqui. Alguém?)

Shtééf
fonte
Obrigado pelos comentários agradáveis! :) Fiz mais algumas edições. Especificamente, o :foreign_keyno has_many :throughnão é necessário e adicionei uma explicação sobre como usar o :dependentparâmetro muito útil para has_many.
Stéphan Kochen
@ Shtééf, mesmo a atribuição em massa (update_attributes) não funcionará no caso de associações bidirecionais, por exemplo: postA.update_attributes ({: post_b_ids => [2,3,4]}) alguma ideia ou solução alternativa?
Lohith MV
Muito boa resposta, companheiro 5.vezes {puts "+1"}
Rahul
@ Shtééf Aprendi muito com essa resposta, obrigado! Tentei perguntar e responder à sua pergunta sobre associação bidirecional aqui: stackoverflow.com/questions/25493368/…
jbmilgrom
17

Para responder à pergunta feita por Shteef:

Associações em loop bidirecionais

A relação seguidor-seguidor entre os usuários é um bom exemplo de uma associação em loop bidirecional. Um usuário pode ter muitos:

  • seguidores na sua qualidade de seguidor
  • seguidores na sua qualidade de seguidor.

Esta é a aparência do código para 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

Veja como o código para 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

As coisas mais importantes a serem observadas são provavelmente os termos :follower_followse :followee_followsem user.rb. Para usar uma associação run of the mill (sem loop) como exemplo, uma equipe pode ter muitos: playersthrough :contracts. Isto não é diferente para um jogador , que pode ter muitos :teamsatravés :contractsbem (ao longo de tais Jogador carreira 's). Mas, neste caso, onde existe apenas um modelo nomeado (ou seja, um usuário ), nomear o relacionamento through: de forma idêntica (por exemplo through: :follow, ou, como foi feito acima no exemplo de postagens, through: :post_connections) resultaria em uma colisão de nomenclatura para diferentes casos de uso de ( ou pontos de acesso para) a tabela de junção. :follower_followse:followee_followsforam criados para evitar tal colisão de nomes. Agora, um usuário pode ter muitos :followersatravés de :follower_followse muitos :followeesatravés :followee_follows.

Para determinar os seguidores de um Usuário : (mediante uma @user.followeeschamada para o banco de dados), Rails pode agora olhar para cada instância de class_name: “Seguir” onde tal Usuário é o seguidor (ou seja foreign_key: :follower_id) através de: tal Usuário : followee_follows. Para determinar os seguidores de um Usuário : (mediante uma @user.followerschamada para o banco de dados), Rails pode agora olhar para cada instância de class_name: “Seguir” onde tal Usuário é o seguidor (isto é foreign_key: :followee_id) por meio de: tal Usuário : follower_follows.

jbmilgrom
fonte
1
Exatamente o que eu precisava! Obrigado! (Recomendo também listar as migrações de banco de dados; tive que colher essa informação da resposta aceita)
Adam Denoon
6

Se alguém viesse aqui para tentar descobrir como criar relacionamentos de amizade no Rails, então eu recomendaria o que eu finalmente decidi usar, que é copiar o que o 'Community Engine' fez.

Você pode se referir a:

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

e

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

Para maiores informações.

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

Inspirado por @ Stéphan Kochen, isso poderia funcionar para associações bidirecionais

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

então post.posts&& post.reversed_postsambos devem funcionar, pelo menos funcionou para mim.

Alba Hoo
fonte
1

Para bidirecional belongs_to_and_has_many, consulte a ótima resposta já postada e, em seguida, crie outra associação com um nome diferente, as chaves estrangeiras invertidas e certifique-se de ter class_namedefinido para apontar de volta para o modelo correto. Felicidades.

Zhenya Slabkovski
fonte
2
Você poderia mostrar um exemplo em sua postagem? Eu tentei de várias maneiras como você sugeriu, mas não consigo acertar.
achabacha322
0

Se alguém teve problemas para fazer a excelente resposta funcionar, como:

(Objeto não suporta #inspect)
=>

ou

NoMethodError: método indefinido `dividir 'para: Missão: Símbolo

Então a solução é substituir :PostConnectionpor "PostConnection", substituindo o nome da classe, é claro.

user2303277
fonte