Статьи

Освойте ассоциации «многие ко многим» с ActiveRecord

Моделирование отношений «многие ко многим» между объектами данных в мире ActiveRecord не всегда простая задача. Даже если у нас есть четко определенная ER-диаграмма для работы, не всегда ясно, какие ассоциации ActiveRecord нам следует использовать и каковы будут последствия нашего решения. Существует два типа отношений «многие ко многим»: транзитивные и непереходные. В математике

Бинарное отношение R транзитивно всякий раз, когда элемент A связан с элементом B, а B, в свою очередь, связан с элементом C.

Чтобы поместить это в контекст моделирования данных, связь между двумя объектами является транзитивной, если ее можно наилучшим образом выразить путем введения одного или нескольких других объектов. Так, например, легко увидеть, что Покупатель покупает у многих Продавцов, а Продавец — многим Покупателям. Тем не менее, отношения не будут полностью выражены, пока мы не начнем добавлять такие объекты, как Product, Payment, Marketplace и так далее. Такие отношения можно назвать переходными от многих ко многим, поскольку мы полагаемся на присутствие других объектов, чтобы полностью уловить семантику отношений. К счастью, ActiveRecord позволяет нам с легкостью моделировать такие отношения. Давайте начнем с рассмотрения простейших ассоциаций ActiveKecord «многие ко многим» и продолжим наш путь.

Непереходные ассоциации

Это самая простая ассоциация «многие ко многим». Две модели связаны простым достоинством их существования. Книга может быть написана многими авторами, а Автор может написать много книг. Это прямая связь, и между двумя моделями существует прямая зависимость. Мы не можем иметь одно без другого. В ActiveRecord это можно легко смоделировать с помощью ассоциации has_and_belongs_to_many (HABTM). Мы можем создать модели и миграции для этого отношения в Rails, выполнив следующие команды:

rails g model Author name:string
rails g model Book title:string
rails g migration CreateJoinTableAuthorsBooks authors books

Нам нужно определить ассоциацию HABTM в наших моделях следующим образом:

 class Book < ApplicationRecord
  has_and_belongs_to_many :authors
end
class Author < ApplicationRecord
  has_and_belongs_to_many :books
end

Затем мы можем создать наши таблицы базы данных, выполнив:

 rails db:migrate

Наконец, мы можем заполнить нашу базу данных:

 herman = Author.create name: 'Herman Melville'
moby = Book.create title: 'Moby Dick'
herman.books << moby

Теперь мы можем, среди прочего, получить доступ: Авторы книги, все Книги, написанные Автором, и все Авторы, написавшие определенную книгу:

 moby.authors
herman.books
herman.books.where(title: 'Moby Dick')

Красиво и просто.

Монотранзитивные ассоциации

Транзитивная связь, которая может быть лучше всего описана с добавлением одной дополнительной модели. Возьмите пример Студента. Студента могут обучать многие преподаватели, а преподаватель может обучать многих студентов, но мы не можем полностью выразить отношения, если не включим другую сущность: Класс (чтобы избежать конфликтов зарезервированных имен в Ruby, давайте назовем это Klass)

 rails g model Student name:string
rails g model Tutor name:string
rails g model Klass subject:string student:references tutor:references

Можно сказать, что ученика обучают на занятиях, а наставник обучает учеников на занятиях. Важное значение здесь имеет сквозное слово, так как мы используем тот же термин в ActiveRecord для определения ассоциации:

 class Student < ApplicationRecord
  has_many :klasses
  has_many :tutors, through: :klasses
end
class Tutor < ApplicationRecord
  has_many :klasses
  has_many :students, through: :klasses
end
class Klass < ApplicationRecord
  belongs_to :student
  belongs_to :tutor
end

Теперь мы можем создать наши таблицы базы данных, выполнив:

 rails db:migrate

Затем мы можем заполнить базу данных:

 bart = Student.create name: 'Bart Simpson'
edna = Tutor.create name: 'Mrs Krabapple'
Klass.create subject: 'Maths', student: bart, tutor: edna

Помимо обычных простых находок мы также можем создавать более сложные запросы:

 Student.find_by(name: 'Bart Simpson').tutors  # find all Bart's tutors
Student.joins(:klasses).where(klasses: {subject: 'Maths'}).distinct.pluck(:name) # get all students who attend the Maths class
Student.joins(:tutors).joins(:klasses).where(klasses: {subject: 'Maths'}, tutors: {name: 'Mrs Krabapple'}).distinct.map {|x| puts x.name} # get all students who attend Maths taught by Mrs Krabapple

Как и в большинстве случаев монотранзитивных ассоциаций, существующие имена моделей неявно отражают ассоциацию (то есть X has_many Z through Y

Мультитранзитивные ассоциации

Мультитранзитивная ассоциация является той, которая может быть выражена лучше всего через многие другие модели Мы, как разработчики, например, связаны со многими сообществами программного обеспечения. Наша ассоциация, однако, принимает разные формы: мы можем добавлять код, размещать сообщения на форумах, посещать мероприятия и многое другое. Каждый разработчик связан с сообществом по-своему через определенные действия. Давайте выберем три из этих действий для нашего примера:

  • Содействующий код
  • Размещение на форумах
  • Посещение событий

Следующим шагом в нашем процессе моделирования является определение объектов данных (моделей), которые помогают реализовать эти действия (ассоциации). Для нашего примера мы можем смело придумать:

ассоциация через модель
вносящий код вместилище
размещение на форумах Форум
посещение мероприятий Событие

Теперь давайте продолжим и создадим нужные нам модели:

 rails g model Community name:string
rails g model Developer name:string
rails g model Repo url:string comment:string developer:references community:references
rails g model Forum url:string post:text developer:references community:references
rails g model Event location:string name:string developer:references community:references
rails db:migrate

Давайте также создадим несколько разработчиков и сообществ:

 devs = %w(joe sue fred mary).map {|dev| Developer.create name: dev}
comms = %w(rails nosql javascript postgres).map {|comm| Community.create name: comm}

Затем мы можем определить ассоциации между нашими моделями. В этот момент у нас может возникнуть соблазн использовать ту же технику, которую мы использовали в монотранзитивном примере, и повторить вызов has_many..through

 class Developer < ApplicationRecord
  has_many :events
  has_many :forums
  has_many :repos
  has_many :appearances, through: :events  #FAIL
  has_many :postings, through: :forums #FAIL
  has_many :contributions, through: :repos #FAIL
end

Однако это не будет работать, так как ActiveRecord попытается вывести имя исходной модели ассоциации из имени ассоциации (например, внешний вид), и произойдет сбой. По этой причине нам нужно указать имя модели источника, используя опцию: :source

 class Developer < ApplicationRecord
  has_many :events
  has_many :forums
  has_many :repos
  has_many :appearances, through: :events, source: :community
  has_many :postings, through: :forums, source: :community
  has_many :contributions, through: :repos, source: :community
end

Точно так же мы делаем то же самое для сообществ:

 class Community < ApplicationRecord
  has_many :events
  has_many :forums
  has_many :repos
  has_many :hostings, through: :events, source: :developer
  has_many :discussions, through: :forums, source: :developer
  has_many :contributions, through: :repos, source: :developer
end

Как вы, возможно, заметили, в модели Сообщества мы меняем названия некоторых ассоциаций, чтобы отразить их природу с этой стороны отношений. Например, разработчик появляется на мероприятиях, а сообщество — на мероприятиях. Разработчик публикует сообщения на форумах, а Сообщество способствует обсуждению на форумах. Таким образом, мы гарантируем, что имена наших методов (которые AR будет динамически создавать на основе наших ассоциаций) будут значимыми и понятными.

Теперь мы можем создавать некоторые события, форумы и репо:

 Repo.create url: 'www.gitlab.com/342', comment: 'ruby code', developer: devs[0], community: comms[0]
Repo.create url: 'www.gitlab.com/662', comment: 'callbacks sample', developer: devs[0], community: comms[2]
Repo.create url: 'www.jsfiddle.com/abcg3', comment: 'reactive sample', developer: devs[1], community: comms[3]
Repo.create url: 'www.jsfiddle.com/563', comment: 'promises sample', developer: devs[2], community: comms[3]
Forum.create url: 'www.stackoverflow.com/mongodb', post: 'this is what I think...', developer: devs[2], community: comms[1]
Forum.create url: 'www.redis.com/563', post: 'my opinion is...', developer: devs[3], community: comms[1]
Event.create location: 'Bath, UK', name: 'Bath Ruby', developer: devs[2], community: comms[0]
Event.create location: 'Tech Institute', name: 'London NoSQL Meetup', developer: devs[2], community: comms[1]

Затем мы можем начать извлекать полезную информацию из наших моделей:

 devs.find_by(name: 'fred').appearances # events a developer has appeared at
Event.find_by(community: comms[0]) # all events for the Rails community
Forum.where(developer: Developer.find_by(name: 'fred') # all forums where a specific developer has posted
Community.find_by(name: 'rails').hostings + Community.find_by(name: 'rails').discussions + Community.find_by(name: 'rails').contributions # get all events, forums and repositories for a specific community
Developer.select('distinct developers.name').joins(:repos).joins(:events).joins(:forums) # find developers who have appeared in Events, contributed to Repos and chatted on Forums, for any Community

Мы можем напрямую использовать ассоциации и / или объединять through

Суть

  • Если между двумя моделями существует прямая связь «многие ко многим», где для описания взаимосвязи не требуется дополнительного семантического пояснения, используйте ассоциацию has_and_belongs_to_many
  • Если отношение «многие ко многим» является косвенным или требует отдельного дополнительного объекта для полного описания, а имя отношения может быть захвачено дополнительным именем модели, используйте ассоциацию has_many :through
  • Если отношение «многие ко многим» имеет нюансы, для описания которых требуется множество других сущностей, используйте ассоциацию has_many :through :source

Моделирование отношений «многие ко многим» с использованием ActiveRecord может оказаться сложной задачей. Как только вы поймете природу каждой ассоциации и опции, которые предлагает ActiveRecord, это значительно облегчит эту задачу.