はじめに
こんにちは。ここ最近のプライベートの開発として、PhoenixによるWebアプリケーションの開発ばかりやっています。
あらかたPhoenixで作るWebアプリケーションの簡単なポンチ絵は決まりそうなので、ここから開発を通じてのネタを執筆していこうと思います。
Webアプリケーションの設計案
現在、Phoenixで作ろうとしているWebアプリケーションの設計案です。
https://github.com/himrock922/we_reports/issues/4
テキストベースだとわかりにくいため、汚いですが、またポンチ絵を
基本的には、会社の仕事やプライベートの活動報告どちらにも対応できるような作業報告システムを作ることです。このポンチ絵をベースに実装を行い、実際に作業内容の提出にとどまらず、作業に費やした時間を計算・グラフ化できるようにしたいと思います。
今回はこの内グループに関係する内容をまとめたいと思います。
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のようなポリモーフィック関連付けに関して記述しているのかと思われます。ただ、ちょっとポリモーフィック関連付けはちゃんとxxx_typeとxxx_idの複合index張っているので、この意見はどうなの?って所はありますが。
色々と調べてはいたのですが、そもそも今、自分が作ろうとしているアプリケーションでポリモーフィズムはやや大げさな気がしたので、結局enumでモデルの種別を変更する方法を採用することにしました。
ecto_enumによるenumでGroupの種別を分ける
論理設計の条件を整理します。
* グループはスポンサーのためのグループであってもいいし、個人プロジェクトを成功させるためのグループであってもいい
* その識別を把握できるような形にしたいため、明確な識別名には分かりやすい名前をつけてあげる
* 更に、グループを整理したい時もあると想定し、typeにindexを張る
% docker exec daily_report_web_1 mix phx.gen.html Groups Group groups name:string description:text
many_to_manyで多対多の関係性を作る
今回は、ユーザとグループの関係性を多対多にしたいので、このモデルでmany_to_manyを作ります。
論理設計の条件を整理します。
* ユーザは複数のグループに所属でき、所属しないユーザがいてもいい
* グループも複数のユーザが所属していいし、所属していない空のグループでもいい
という事で、groupのテーブルを以下のようにしました。
% docker exec daily_report_web_1 mix ecto.gen.migration create_groups_users
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の中間テーブルは以下のようにしました。
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
GrouprとUserモデルの関係性もここで設定しておきます。
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によって、紐づくテーブルは全て削除されるようになります。
schema "users" do
field :password, :string
field :username, :string
many_to_many :groups, DailyReport.Groups.Group, join_through: "groups_users"
timestamps()
end
これをmigrateし、後はうまい具合にView側をカスタマイズしてあげます。
グループ新規作成のフォームのサンプル例としては以下のようなものを作りました。
% 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アクションに柔軟に対応できるような書き方にしようと思います。
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
テーブルの中身を見てみましょう。
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を参照して下さい。
% mix gettext.merge priv/gettext --locale ja
config :gettext, :default_locale, "ja"
configを変えた後は再コンパイルが必要となるので、docker-compose restartをかけます。
参考文献
- https://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3-polymorphic-associations
- https://github.com/gjaldon/ecto_enum
- https://github.com/gjaldon/ecto_enum#using-postgress-enum-type
- https://www.postgresql.org/docs/current/datatype-enum.html
- https://elixirschool.com/en/lessons/ecto/associations/#many-to-many
- https://medium.com/@ricardoruwer/many-to-many-associations-in-elixir-and-phoenix-b4aa6d978f7b
- https://hexdocs.pm/guardian/tutorial-start.html
- https://elixirschool.com/ja/lessons/basics/pipe-operator/
- https://github.com/elixir-ecto/ecto/issues/1542#issuecomment-231010049
- https://www.koga1020.com/posts/ecto-assoc-functions
- 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.Changeset.html#module-associations-embeds-and-on-replace