Связь "многие-ко-многим" с той же моделью в рельсах?

107

Как я могу создать отношение "многие ко многим" с той же моделью в рельсах?

Например, каждый пост связан со многими постами.

Виктор
источник

Ответы:

276

Есть несколько видов отношений «многие ко многим»; вы должны задать себе следующие вопросы:

  • Хочу ли я хранить дополнительную информацию в ассоциации? (Дополнительные поля в объединенной таблице.)
  • Должны ли ассоциации быть неявно двунаправленными? (Если сообщение A связано с сообщением B, то сообщение B также связано с сообщением A.)

Это оставляет четыре различных возможности. Я пройдусь по ним ниже.

Для справки: документация Rails по этой теме . Есть раздел под названием «Многие-ко-многим» и, конечно же, документация по самим методам класса.

Самый простой сценарий, однонаправленный, без дополнительных полей

Это самый компактный код.

Я начну с этой базовой схемы ваших сообщений:

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

Для любых отношений «многие ко многим» вам понадобится таблица соединений. Вот схема для этого:

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

По умолчанию Rails будет называть эту таблицу комбинацией имен двух таблиц, которые мы объединяем. Но что бы вышло как posts_postsв данной ситуации, поэтому решил взять post_connectionsвзамен.

Здесь очень важно :id => falseопустить idстолбец по умолчанию . Rails хочет, чтобы этот столбец был везде, кроме таблиц соединений дляhas_and_belongs_to_many . Он будет громко жаловаться.

Наконец, обратите внимание, что имена столбцов также нестандартны (не post_id), чтобы предотвратить конфликт.

Теперь в вашей модели вам просто нужно сообщить Rails об этих нескольких нестандартных вещах. Это будет выглядеть так:

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

И это должно просто работать! Вот пример выполнения сеанса 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!">]

Вы обнаружите, что назначение postsассоциации приведет к созданию соответствующих записей в post_connectionsтаблице.

Несколько замечаний:

  • Вы можете видеть в приведенном выше сеансе irb, что ассоциация является однонаправленной, потому что после a.posts = [b, c]этого выводb.posts не включает первое сообщение.
  • Еще вы могли заметить, что модели нет PostConnection. Обычно вы не используете модели для has_and_belongs_to_manyассоциации. По этой причине у вас не будет доступа к дополнительным полям.

Однонаправленный, с дополнительными полями

А теперь ... У вас есть постоянный пользователь, который сегодня написал на вашем сайте сообщение о том, насколько вкусны угри. Этот совершенно незнакомый человек заходит на ваш сайт, регистрируется и пишет ругательство о некомпетентности обычного пользователя. В конце концов, угри - вымирающий вид!

Итак, вы хотели бы прояснить в своей базе данных, что пост B - это брань против поста A. Для этого вы хотите добавить categoryполе в ассоциацию.

Однако то , что нам нужно , чем дольше нет has_and_belongs_to_many, но сочетание has_many, belongs_to, has_many ..., :through => ...и дополнительные модели для объединения таблицы. Эта дополнительная модель дает нам возможность добавлять дополнительную информацию к самой ассоциации.

Вот еще одна схема, очень похожая на приведенную выше:

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

Обратите внимание , как в этой ситуации, post_connections действительно есть idстолбец. ( Параметров нет :id => false .) Это необходимо, потому что будет обычная модель ActiveRecord для доступа к таблице.

Начну с PostConnectionмодели, потому что она предельно проста:

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

Единственное , что здесь происходит :class_name, что необходимо, потому что Rails не может вывести из post_aили post_bчто мы имеем дело с пост здесь. Мы должны сказать это прямо.

Сейчас Post модель:

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

С первой has_many ассоциацией, мы говорим модели присоединиться post_connectionsна posts.id = post_connections.post_a_id.

Со второй ассоциацией мы говорим Rails, что можем достичь других сообщений, связанных с этим, через нашу первую ассоциацию. post_connections , за которой следует post_bассоциация PostConnection.

Не хватает только одной вещи : нам нужно сообщить Rails, что a PostConnectionзависит от сообщений, которым он принадлежит. Если бы одно или оба post_a_idи post_b_idбылиNULL , то эта связь не рассказала бы нам много, не так ли? Вот как мы это делаем в нашей Postмодели:

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

Помимо небольшого изменения синтаксиса, здесь есть две разные вещи:

  • has_many :post_connectionsИмеет дополнительный :dependentпараметр. Со значением:destroy мы говорим Rails, что, как только этот пост исчезнет, ​​он может продолжить и уничтожить эти объекты. Альтернативное значение, которое вы можете использовать здесь, - :delete_allэто быстрее, но не будет вызывать никаких обработчиков уничтожения, если вы их используете.
  • Мы также добавили has_manyассоциацию для обратных связей, через которые мы связались post_b_id. Таким образом, Rails может аккуратно уничтожить и их. Обратите внимание, что мы должны указать :class_nameздесь, потому что имя класса модели больше не может быть выведено из:reverse_post_connections .

Теперь я предлагаю вам еще один сеанс irb 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

Вместо того, чтобы создавать ассоциацию и затем устанавливать категорию отдельно, вы также можете просто создать PostConnection и покончить с этим:

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

И мы также можем манипулировать post_connectionsи reverse_post_connectionsассоциации; в postsассоциации аккуратно отразится :

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

Двунаправленные петлевые ассоциации

В нормальных has_and_belongs_to_manyассоциациях ассоциация определяется как в задействованных моделях. И ассоциация двунаправленная.

Но в данном случае есть только одна модель Post. И ассоциация указывается только один раз. Именно поэтому в данном конкретном случае ассоциации однонаправлены.

То же самое верно для альтернативного метода has_manyи модели для таблицы соединения.

Это лучше всего видно, если просто получить доступ к ассоциациям из irb и посмотреть на SQL, который Rails генерирует в файле журнала. Вы найдете что-то вроде следующего:

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

Чтобы сделать ассоциацию двунаправленной, нам нужно было бы найти способ сделать Rails ORуказанными выше условиями с post_a_idи post_b_idнаоборот, чтобы он смотрел в обоих направлениях.

К сожалению, единственный известный мне способ сделать это довольно хакерский. Вам придется вручную указать свой SQL, используя has_and_belongs_to_manyтакие параметры , как :finder_sql, :delete_sqlи т. Д. Это некрасиво. (Я тоже открыт для предложений. Кто угодно?)

Shtéf
источник
Спасибо за хорошие комментарии! :) Я внес еще несколько правок. В частности, :foreign_keyна has_many :throughэто не нужно, и я добавил объяснение о том , как использовать очень удобный :dependentпараметр для has_many.
Стефан Кохен 01
@ Shtééf даже массовое назначение (update_attributes) не будет работать в случае двунаправленных ассоциаций, например: postA.update_attributes ({: post_b_ids => [2,3,4]}) какие-нибудь идеи или обходные пути?
Lohith MV
Очень хороший ответ, товарищ 5 раз {ставит «+1»}
Рахул,
@ Shtéf Я многому научился из этого ответа, спасибо! Я попытался задать и ответить на ваш вопрос о двунаправленной ассоциации здесь: stackoverflow.com/questions/25493368/…
jbmilgrom
17

Чтобы ответить на вопрос, заданный Штифом:

Двунаправленные петлевые ассоциации

Отношения последователь-подписчик между пользователями - хороший пример двунаправленной зацикленной ассоциации. У пользователя может быть много:

  • последователи в качестве последователей
  • последователи в качестве последователя.

Вот как может выглядеть код для 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

Вот как выглядит код для 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

Наиболее важные моменты, на которые следует обратить внимание, это, вероятно, термины :follower_followsи :followee_followsв user.rb. Чтобы использовать обычную ассоциацию (без зацикливания) в качестве примера, у команды может быть много: playersthrough :contracts. Это ничем не отличается от игрока , у которого тоже может быть много :teamsрезультатов :contracts(в течение карьеры такого игрока ). Но в этом случае, когда существует только одна именованная модель (например, Пользователь ), идентичное наименование отношения through: (например through: :follow, или, как это было сделано выше в примере сообщений through: :post_connections) приведет к конфликту имен для разных вариантов использования ( или точки доступа в) таблицу соединений. были созданы, чтобы избежать такого конфликта имен. Теперь:follower_followsи:followee_followsУ пользователя может быть много :followersсквозных :follower_followsи много :followeesсквозных :followee_follows.

Для определения пользователя «s: followees (при в @user.followeesобращении к базе данных), Rails может выглядеть на каждом экземпляре class_name:„Follow“ , где такой Пользователь является последователем (то есть foreign_key: :follower_id) через: такого пользователя «ы: followee_follows. Чтобы определить количество подписчиков пользователя : (при @user.followersобращении к базе данных), Rails теперь может просматривать каждый экземпляр class_name: «Follow», где такой пользователь является последующим (т. Е. foreign_key: :followee_idЧерез: таким пользователем : follower_follows).

jbmilgrom
источник
1
Именно то, что мне нужно! Спасибо! (Я рекомендую также перечислить миграции базы данных; мне пришлось почерпнуть эту информацию из принятого ответа)
Адам Денун
6

Если бы кто-нибудь пришел сюда, чтобы попытаться узнать, как создавать дружеские отношения в Rails, я бы отослал их к тому, что я в конце концов решил использовать, а именно к копированию того, что сделал «Community Engine».

Вы можете обратиться к:

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

и

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

Чтобы получить больше информации.

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
источник
2

Вдохновленный @ Stéphan Kochen, это может сработать для двунаправленных ассоциаций.

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

тогда post.posts&& post.reversed_postsдолжен работать, по крайней мере, работал у меня.

Альба Ху
источник
1

Для двунаправленного действия belongs_to_and_has_manyобратитесь к уже опубликованному отличному ответу, а затем создайте еще одну ассоциацию с другим именем, внешние ключи поменяны местами и убедитесь, что вы class_nameустановили указатель обратно на правильную модель. Ура.

Женя Слабковский
источник
2
Не могли бы вы показать пример в своем посте? Я пробовал несколько способов, как вы предложили, но, похоже, не могу это решить.
achabacha322
0

Если у кого-то были проблемы с получением отличного ответа на работу, например:

(Объект не поддерживает #inspect)
=>

или

NoMethodError: неопределенный метод `split 'для: Mission: Symbol

Тогда решение - заменить :PostConnectionна "PostConnection", конечно, подставив свое имя класса.

user2303277
источник