Существует несколько видов отношений «многие ко многим»; Вы должны задать себе следующие вопросы:
- Хочу ли я хранить дополнительную информацию в ассоциации? (Дополнительные поля в соединительной таблице.)
- Должны ли ассоциации быть неявно двунаправленными?
(Если сообщение 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, что 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
и т. Д. Это не красиво. (Я тоже открыт для предложений. Кто-нибудь?)