PhoenixによるWeb開発 - 多対多なテーブルの紐付けを行う -

## はじめに

こんにちは。ここ最近のプライベートの開発として、PhoenixによるWebアプリケーションの開発ばかりやっています。
あらかたPhoenixで作るWebアプリケーションの簡単なポンチ絵は決まりそうなので、ここから開発を通じてのネタを執筆していこうと思います。

## Webアプリケーションの設計案

現在、Phoenixで作ろうとしているWebアプリケーションの設計案です。
https://github.com/himrock922/we_reports/issues/4
テキストベースだとわかりにくいため、汚いですが、またポンチ絵を
![ponchie.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144991/e029e17f-e03d-0318-2e7c-28ed85da8370.png)

基本的には、会社の仕事やプライベートの活動報告どちらにも対応できるような作業報告システムを作ることです。このポンチ絵をベースに実装を行い、実際に作業内容の提出にとどまらず、作業に費やした時間を計算・グラフ化できるようにしたいと思います。

今回はこの内グループに関係する内容をまとめたいと思います。

## Phoenixっぽい(?)ポリモーフィズム

まずグループの整理を行いたいと思います。

```
* グループはスポンサーのためのグループであってもいいし、個人プロジェクトを成功させるためのグループであってもいい
* その識別を把握できるような形にしたいため、明確な識別名には分かりやすい名前をつけてあげる
* 更に、グループを整理したい時もあると想定し、typeにindexを張る
```

また、ユーザの整理は以下のようにします。

```
* ユーザは複数のグループに所属でき、所属しないユーザがいてもいい
* グループも複数のユーザが所属していいし、所属していない空のグループでもいい
```

この内、グループのスポンサーや個人プロジェクト等特定のステートに依存しないように、ただし後からの識別は容易にできるような方法としてRailsのポリモーフィズムのようなものがPhoenixにないかと思い最初にこの部分を調べていました。

Phoenixの公式サイトで、以下のような項目を見つけました。
https://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3-polymorphic-associations

要約するとこんな感じ

```
Commentスキーマを定義し、tasks,posts両方のコメントするために利用したいとします。
いくつかの抽象化により、データベースに2つのフィールドと何らかの多様的な関連付けを定義する必要が生じます。

* commentable_type
* commentable_id

このアプローチの問題はデータベース内の参照が破損することです。外部キーを使用することができず、クエリ時間とストレージの両方の点で非常に非効率的です。
```

恐らく、[Railsのようなポリモーフィック関連付け](https://railsguides.jp/association_basics.html#%E3%83%9D%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%83%83%E3%82%AF%E9%96%A2%E9%80%A3%E4%BB%98%E3%81%91)に関して記述しているのかと思われます。ただ、ちょっとポリモーフィック関連付けはちゃんとxxx_typeとxxx_idの複合index張っているので、この意見はどうなの?って所はありますが。

色々と調べてはいたのですが、そもそも今、自分が作ろうとしているアプリケーションでポリモーフィズムはやや大げさな気がしたので、結局enumでモデルの種別を変更する方法を採用することにしました。

## ecto_enumによるenumでGroupの種別を分ける

論理設計の条件を整理します。

```
* グループはスポンサーのためのグループであってもいいし、個人プロジェクトを成功させるためのグループであってもいい
* その識別を把握できるような形にしたいため、明確な識別名には分かりやすい名前をつけてあげる
* 更に、グループを整理したい時もあると想定し、typeにindexを張る
```

```bash
% docker exec daily_report_web_1 mix phx.gen.html Groups Group groups name:string description:text
```

## many_to_manyで多対多の関係性を作る

今回は、ユーザとグループの関係性を多対多にしたいので、このモデルでmany_to_manyを作ります。
論理設計の条件を整理します。

```
* ユーザは複数のグループに所属でき、所属しないユーザがいてもいい
* グループも複数のユーザが所属していいし、所属していない空のグループでもいい
```

という事で、groupのテーブルを以下のようにしました。

```bash
% docker exec daily_report_web_1 mix ecto.gen.migration create_groups_users
```


```elixir:20200307053557_create_groups.exs
defmodule WeReports.Repo.Migrations.CreateGroups do
  use Ecto.Migration
  import EctoEnum
  defenum StatusEnum, :type, [:sponsor, :project]
  def change do
    StatusEnum.create_type
    create table(:groups) do
      add :name, :string
      add :description, :text
      add :type_name, StatusEnum.type()
      timestamps()
    end
    create index(:groups, [:name])
    create index(:groups, [:type_name])
    create index(:groups, [:name, :type_name])
  end
end
```

また、GroupとUserの中間テーブルは以下のようにしました。

```elixir:20200307052507_create_users_groups.exs
defmodule WeReports.Repo.Migrations.CreateGroupsUsers do
  use Ecto.Migration

  def change do
    create table(:groups_users) do
      add :group_id, references(:groups)
      add :user_id, references(:users)
    end
    create index(:groups_users, [:group_id, :user_id])
  end
end
```

GroupｒとUserモデルの関係性もここで設定しておきます。

```elixir:lib/daily_report/groups/group.ex
defmodule WeReports.Groups.Group do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset

  schema "groups" do
    field :description, :string
    field :name, :string
    field :type_name, :string
    many_to_many :users, WeReports.UserManager.User, join_through: "groups_users", on_replace: :delete, on_delete: :delete_all
    timestamps()
  end

  @doc false
  def changeset(group, attrs) do
    group
    |> cast(attrs, [:name, :description, :type_name])
    |> validate_required([:name, :description, :type_name])
  end
end
```

ここで`on_replace`は親モデルであるGroupの置き換えが行われた時、関係するモデルに対して行われるアクションを指定します。ここをdeleteにすることで既存の紐づくテーブルを一旦削除してから、insertを行う事ができます。動的に加入するユーザが変わるのであれば、このdeleteが適切と判断しました。
また、`on_delete`は親モデルであるGroupの削除が行われた時、関係するモデルに対して行われるアクションを指定します。delete_allによって、紐づくテーブルは全て削除されるようになります。

```elixir:lib/daily_report/user_manager/user.ex
  schema "users" do
    field :password, :string
    field :username, :string
    many_to_many :groups, DailyReport.Groups.Group, join_through: "groups_users"
    timestamps()
  end
```

これをmigrateし、後はうまい具合にView側をカスタマイズしてあげます。

グループ新規作成のフォームのサンプル例としては以下のようなものを作りました。

![新規グループ作成フォーム.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144991/b20bc627-df8a-a52a-2dd0-0c6777578be6.png)

```bash
% docker exec daily_report_web_1 mix ecto.migrate
```

## put_assocで中間テーブルの作成を行う

グループ新規作成と共にグループに所属するユーザを確定させるために中間テーブルの保存を行います。
保存のためのAPIとしてput_assocを利用することにしました。

put_assocは関連するデータの完全置き換えや完全削除を行う事ができます。
似たようなAPIとしてcast_assocがあります。これとの違いは、cast_assocでは関連するデータを一括に生成するようにできるので、POSTパラメータを送るようなFormに適しており、既に存在している構造体に紐づく構造体の完全置き換えや削除を行いたいのであればput_assocを利用するのが適しているように見えます。

今回は、put_assocを使いcreate,updateアクションに柔軟に対応できるような書き方にしようと思います。

```elixir:lib/we_reports/groups.ex
defmodule WeReports.Groups do
  def create_group(attrs \\ %{}) do
    %Group{}
    |> Group.changeset(attrs)
    |> multiple_put_users(attrs)
    |> Repo.insert()
  end

  defp multiple_put_users(changeset, attrs) do
    users = UserManager.get_users(attrs["groups_users"])
    Ecto.Changeset.put_assoc(changeset, :users, users)
  end
end
```

テーブルの中身を見てみましょう。

```bash
we_reports_dev=# select * from groups_users;
 id | group_id | user_id
----+----------+---------
  1 |        1 |       1
  2 |        2 |       1
(2 rows)

we_reports_dev=# select * from users;
 id |  username  |                                              password                                              |     inserted_at     |     updat
ed_at
----+------------+----------------------------------------------------------------------------------------------------+---------------------+----------
-----------
  1 | himrock922 | ***** | 2020-03-09 13:50:25 | 2020-03-0
9 13:50:25
(1 row)

we_reports_dev=# select * from groups_users;
 id | group_id | user_id
----+----------+---------
  1 |        1 |       1
  2 |        2 |       1
(2 rows)

we_reports_dev=# select * from groups;
 id |            name             |         description         | type_name |     inserted_at     |     updated_at
----+-----------------------------+-----------------------------+-----------+---------------------+---------------------
  1 | ***** | *****  | project   | 2020-03-21 00:58:03 | 2020-03-21 00:58:03
  2 | ***** | ***** | project   | 2020-03-21 00:58:23 | 2020-03-21 00:58:23
```

##　おまけ

### PhoenixによるWebアプリケーションのlocaleを変更する

超今更ですが、今までlocaleを変更せずに開発を行っていたので、この辺りでlocaleの変更を行おうと思います。
Phoenixではデフォルトで`gettext`と呼ばれる多言語メッセージ生成を支援するElixir用のWrapperが入っています。
これを利用し、翻訳用のファイルを作成し、そこから各メッセージに対応する文章の紐付けを行います。
詳しくは、[https://hexdocs.pm/guardian/tutorial-start.html](https://hexdocs.pm/guardian/tutorial-start.html)を参照して下さい。

```bash
% mix gettext.merge priv/gettext --locale ja
```

```elixir:config.exs
config :gettext, :default_locale, "ja"
```

configを変えた後は再コンパイルが必要となるので、docker-compose restartをかけます。


## 参考文献


* [https://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3-polymorphic-associations](https://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3-polymorphic-associations)
* [https://github.com/gjaldon/ecto_enum](https://github.com/gjaldon/ecto_enum)
* [https://github.com/gjaldon/ecto_enum#using-postgress-enum-type](https://github.com/gjaldon/ecto_enum#using-postgress-enum-type)
* [https://www.postgresql.org/docs/current/datatype-enum.html](https://www.postgresql.org/docs/current/datatype-enum.html)
* [https://elixirschool.com/en/lessons/ecto/associations/#many-to-many](https://elixirschool.com/en/lessons/ecto/associations/#many-to-many)
* [https://medium.com/@ricardoruwer/many-to-many-associations-in-elixir-and-phoenix-b4aa6d978f7b](https://medium.com/@ricardoruwer/many-to-many-associations-in-elixir-and-phoenix-b4aa6d978f7b)
* [https://hexdocs.pm/guardian/tutorial-start.html](https://hexdocs.pm/guardian/tutorial-start.html)
* [https://elixirschool.com/ja/lessons/basics/pipe-operator/](https://elixirschool.com/ja/lessons/basics/pipe-operator/)
* [https://github.com/elixir-ecto/ecto/issues/1542#issuecomment-231010049](https://github.com/elixir-ecto/ecto/issues/1542#issuecomment-231010049)
* [https://www.koga1020.com/posts/ecto-assoc-functions](https://www.koga1020.com/posts/ecto-assoc-functions)
* [https://elixirforum.com/t/how-to-check-if-a-value-is-empty-in-eex/1659](https://elixirforum.com/t/how-to-check-if-a-value-is-empty-in-eex/1659)
* [https://hexdocs.pm/ecto/Ecto.Schema.html](https://hexdocs.pm/ecto/Ecto.Schema.html)
* [https://hexdocs.pm/ecto/Ecto.Changeset.html#module-associations-embeds-and-on-replace](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-associations-embeds-and-on-replace)