
Иногда использование третьей таблицы для связи многое ко многим не есть необходимым и добавляет в разработку проекта дополнительные сложности. Попытаемся уйти от использования третьей таблицы используя столбец типа массив добавленный в PostgreSQL 9.1
Давайте создадим небольшое приложение на Elixir Phoenix с названием Demo для демонстрации:
$ mix phoenix.new demo $ cd demo
Проверим, что в порядке используя созданные тесты:
$ mix test
Теперь создадим модель Group и Post, которая будет принадлежать к Group:
$ mix phoenix.gen.model Group groups name:string $ mix phoenix.gen.model Post posts name:string body:text group_id:references:groups
Теперь мы хотим создать модель пользователя (User), который может принадлежать нескольким группам. Так же пользователь будет иметь доступ только к записям Post из собственных групп. Вместо того, чтобы создавать третью таблицу для связи users и groups давайте добавим к таблице users колонку group_ids:
$ mix phoenix.gen.model User users name:string group_ids:array:integer
Вот как выглядит модель User:
# web/models/user.ex defmodule Demo.User do use Demo.Web, :model schema "users" do field :name, :string field :group_ids, {:array, :integer} timestamps() end @doc """ Builds a changeset based on the `struct` and `params`. """ def changeset(struct, params \\ %{}) do struct |> cast(params, [:name, :group_ids]) |> validate_required([:name, :group_ids]) end end
Заметьте, что метод changeset позволяет изменять group_ids. Так что, если мы будем использовать этот метод для редактирования профайла пользователя самим пользователем, то пользователь сможет добавить себя в любую группу. Если такая логика Вам не подходит, то можно добавить дополнительную валидацию, удостоверяющую, что значение group_ids является подмножеством разрешенных для пользователя групп. Ну или же можно просто запретить пользователю изменять group_ids.
Мы также можем добавить индекс на group_ids:
CREATE INDEX users_group_ids_rdtree_index ON users USING GIST (group_ids gist__int_ops);
Можете создать дополнительную миграцию для этого.
Теперь спланируем метод Post.accessible_by/2, который будет возвращать все записи Post из доступных для пользователя групп. Для этого создадим тест:
# test/models/post_test.exs defmodule Demo.PostTest do use Demo.ModelCase alias Demo.{Post, Group, User} # Опущены тесты метода changeset test "accessible for user" do g1 = %Group{} |> Repo.insert! g2 = %Group{} |> Repo.insert! g3 = %Group{} |> Repo.insert! %Post{group_id: g1.id} |> Repo.insert! p21 = %Post{group_id: g2.id} |> Repo.insert! p22 = %Post{group_id: g2.id} |> Repo.insert! p31 = %Post{group_id: g3.id} |> Repo.insert! user = %User{group_ids: [g2.id, g3.id]} |> Repo.insert! post_ids = Post |> Post.accessible_by(user) |> Ecto.Query.order_by(:id) |> Repo.all |> Enum.map(&(&1.id)) assert post_ids == [p21.id, p22.id, p31.id] end end
Реализация метода:
# web/models/post.ex defmodule Demo.Post do use Demo.Web, :model schema "posts" do field :name, :string field :body, :string belongs_to :group, Demo.Group timestamps() end @doc """ Builds a changeset based on the `struct` and `params`. """ def changeset(struct, params \\ %{}) do struct |> cast(params, [:name, :body]) |> validate_required([:name, :body]) end def accessible_by(query, user) do from p in query, where: p.group_id in ^user.group_ids end end
Тут мы и получаем все записи Post из всех групп пользователя.
Мы можем пойти далее и разрешить записи Post принадлежать сразу нескольким группам. Для этого добавим колонку group_ids к таблице posts так же как и для таблицы users, а колонку group_id удалим. Теперь запись Post будет доступна для пользователя тогда и только тогда когда в массиве group_ids у записи Post и в массиве group_ids пользователя есть хотя бы один общий элемент.
Для этого мы можем использовать оператор перекрытия в PostgreSQL. Измененная модель Post:
# web/models/post.ex defmodule Demo.Post do use Demo.Web, :model schema "posts" do field :name, :string field :body, :string field :group_ids, {:array, :integer} timestamps() end @doc """ Builds a changeset based on the `struct` and `params`. """ def changeset(struct, params \\ %{}) do struct |> cast(params, [:name, :body, :group_ids]) |> validate_required([:name, :body, :group_ids]) end def accessible_by(query, user) do from p in query, where: fragment("? && ?", p.group_ids, ^user.group_ids) end end
В качестве упражнения, можете обновить также миграцию для создания таблицы posts и тесты модели Post. Не забудьте добавить индекс на колонку group_ids в таблице posts.
Надеюсь это будет хотя бы кому-то полезно. Спасибо.