Иногда использование третьей таблицы для связи многое ко многим не есть необходимым и добавляет в разработку проекта дополнительные сложности. Попытаемся уйти от использования третьей таблицы используя столбец типа массив добавленный в 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.
Надеюсь это будет хотя бы кому-то полезно. Спасибо.
Автор: shhavel