Pull to refresh

Отношение многие ко многим без третьей таблицы в PostgreSQL используя Elixir Ecto

Reading time 3 min
Views 12K
image

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

Надеюсь это будет хотя бы кому-то полезно. Спасибо.
Tags:
Hubs:
+24
Comments 28
Comments Comments 28

Articles