(追記)2022/10/02 Phoenix1.6に合わせて更新し、大幅に加筆しました
今回も割と地味目な話です。Phoenix1.3の基本的な話です。
本記事ではGenerator(phx.gen.html)の吐き出すコードを確認することで、Phoenixの基本的な仕組みを追ってみたいと思います。本記事を読むことでPhoenixによるCRUDアプリの骨組みを理解することができます。(多分)
【過去記事】
- 
LiveView関連 
 東京電力電力供給状況監視 - Phoenix LiveView
 Phoenix LiveView と キーボードイベント - Qiita
 Phoenix LiveView の JavaScript Hook - Qiita
 Phoenix LiveViewの基本設定 - Qiita
 Phoenix1.6の基本的な仕組み - Qiita
- 
認証関連 
 Phoenix 認証システム - mix phx.gen.auth
 Elixir/Phoenix のシンプル認証 auth_plug
- 
Ecto関連 
 Elixir Ecto チュートリアル - Qiita
 Elixir Ecto のまとめ - Qiita
 Elixir Ecto Association - Oiita
1.Generator - phx.gen.html
プロジェクトを作成し、DBを初期化します。
mix phx.new people
cd people
mix ecto.setup
generatorのコマンドphx.gen.htmlを以下のように実行します。このgeneratorが必要なmoduleを全て生成してくれます。generatorを使わず手作業でmoduleを作るときも、generatorで生成したものを参考にすれば良いわけです。
mix phx.gen.html Users User users first_name:string last_name:string age:integer
引数の意味は下記の表の通りです。このコマンドにより、それぞれ表にあるようなDB関連のElixir moduleを自動生成します。
| 引数 | 値 | 意味 | 補足 | 
|---|---|---|---|
| 1 | Users | context module | |
| 2 | User | schema module | |
| 3 | users | schema table name | schema moduleの複数形 | 
以下自動生成されたファイルに日本語でコメントを加えていきます。
2.DB関連モジュール
2-1.context module
ドキュメントでは、context はresourceのAPI boundaryを提供するElixir moduleです、と述べられています。つまり context は、schemas や database に対して複数の操作が必要な時に、それらを一つの関数にまとめて、シンプルなインターフェースを提供してくれるmoduleです。ラッパー関数の集まりです。 controllerからdatabaseにアクセスするときなどは、関数を一発呼ぶだけで、複雑な操作はこのmoduleで行ってくれます。
 (注意)users.exは自動生成されたものですが、長くなるのでコメントはカットしてあります。 <=コメントもそのまま掲載してます。
defmodule People.Users do
  @moduledoc """
  The Users context.
  """
  import Ecto.Query, warn: false
  alias People.Repo
  alias People.Users.User
  @doc """
  Returns the list of users.
  ## Examples
      iex> list_users()
      [%User{}, ...]
  """
  def list_users do
    Repo.all(User)
  end
  @doc """
  Gets a single user.
  Raises `Ecto.NoResultsError` if the User does not exist.
  ## Examples
      iex> get_user!(123)
      %User{}
      iex> get_user!(456)
      ** (Ecto.NoResultsError)
  """
  def get_user!(id), do: Repo.get!(User, id)
  @doc """
  Creates a user.
  ## Examples
      iex> create_user(%{field: value})
      {:ok, %User{}}
      iex> create_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}
  """
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end
  @doc """
  Updates a user.
  ## Examples
      iex> update_user(user, %{field: new_value})
      {:ok, %User{}}
      iex> update_user(user, %{field: bad_value})
      {:error, %Ecto.Changeset{}}
  """
  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end
  @doc """
  Deletes a user.
  ## Examples
      iex> delete_user(user)
      {:ok, %User{}}
      iex> delete_user(user)
      {:error, %Ecto.Changeset{}}
  """
  def delete_user(%User{} = user) do
    Repo.delete(user)
  end
  @doc """
  Returns an `%Ecto.Changeset{}` for tracking user changes.
  ## Examples
      iex> change_user(user)
      %Ecto.Changeset{data: %User{}}
  """
  def change_user(%User{} = user, attrs \\ %{}) do
    User.changeset(user, attrs)
  end
end
2-2.schema module
Schema は DB テーブルを Elixir struct に map するために使われます。 テーブルの定義をstructに反映したものになります。changeset の定義も行われます。schemaとchangesetについては以下の過去記事を参照してください。
Elixir Ecto チュートリアル - Qiita
Elixir Ecto のまとめ - Qiita
defmodule People.Users.User do
  use Ecto.Schema
  import Ecto.Changeset
  schema "users" do
    field :age, :integer
    field :first_name, :string
    field :last_name, :string
    timestamps()      #time関連の項目が自動で追加
  end
  #schemaとchangesetはペアで定義されます。
  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:first_name, :last_name, :age])
    |> validate_required([:first_name, :last_name, :age])
  end
end
2-3.schema table
schema table の定義ファイルです。
defmodule People.Repo.Migrations.CreateUsers do
  use Ecto.Migration
  def change do
    create table(:users) do    # schema tableのcreateの定義
      add :first_name, :string
      add :last_name, :string
      add :age, :integer
      timestamps()    #time関連の項目が自動で追加
    end
  end
end
以下のコマンドで実際にschema tableを作成します。
mix ecto.migrate
3.routerモジュール
Router はクライアントからのリクエストを、パスを基に、適切なController(の関数)に振り分ける役割を果たします。
phx.gen.htmlコマンドで、router.exに以下の一行を追加するようガイドされます。手動で追加します。
defmodule PeopleWeb.Router do
  use PeopleWeb, :router
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end
  pipeline :api do
    plug :accepts, ["json"]
  end
  scope "/", PeopleWeb do
    pipe_through :browser # Use the default browser stack
    get "/", PageController, :index
    resources "/users", UserController          # これを追加する
  end
  # Other scopes may use custom stacks.
  # scope "/api", PeopleWeb do
  #   pipe_through :api
  # end
end
resources "/users", UserController行の追加の結果を mix phx.routes コマンドで確認します。==> Routing
---
user_path  GET     /users                                 PeopleWeb.UserController :index
user_path  GET     /users/:id/edit                        PeopleWeb.UserController :edit
user_path  GET     /users/new                             PeopleWeb.UserController :new
user_path  GET     /users/:id                             PeopleWeb.UserController :show
user_path  POST    /users                                 PeopleWeb.UserController :create
user_path  PATCH   /users/:id                             PeopleWeb.UserController :update
           PUT     /users/:id                             PeopleWeb.UserController :update
user_path  DELETE  /users/:id                             PeopleWeb.UserController :delete
---
4.controllerモジュールとViewモジュール
phx.gen.htmlは、Controller と View のElixir moduleも自動生成します。
4-1.Controller
Controller はクライアントからのリクエストを処理する関数の集まりです。以下のソースコードに説明コメントを追加しました。
※以下、ContorollerからTemplateに渡されるusersやchangeset等のデータを明確にするため(4-1)等の番号を振ってあります。
defmodule PeopleWeb.UserController do
  use PeopleWeb, :controller
  alias People.Users
  alias People.Users.User
  # トップページ、user一覧を表示する
  def index(conn, _params) do
    users = Users.list_users()
    render(conn, "index.html", users: users)
  end
  # user作成画面を表示する
  def new(conn, _params) do
    changeset = Users.change_user(%User{}) 
    render(conn, "new.html", changeset: changeset)
  end
  # user作成を実行する。
  def create(conn, %{"user" => user_params}) do
    case Users.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
  # idで指定されたuserを表示する
  def show(conn, %{"id" => id}) do
    user = Users.get_user!(id)
    render(conn, "show.html", user: user)
  end
  #  idで指定されたuserの編集画面を表示する
  def edit(conn, %{"id" => id}) do
    user = Users.get_user!(id)
    changeset = Users.change_user(user)
    render(conn, "edit.html", user: user, changeset: changeset)
  end
  # user編集を実行する
  def update(conn, %{"id" => id, "user" => user_params}) do
    user = Users.get_user!(id)
    case Users.update_user(user, user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User updated successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", user: user, changeset: changeset)
    end
  end
  # userを削除する
  def delete(conn, %{"id" => id}) do
    user = Users.get_user!(id)
    {:ok, _user} = Users.delete_user(user)
    conn
    |> put_flash(:info, "User deleted successfully.")
    |> redirect(to: user_path(conn, :index))
  end
end
(1) Rendering - render(conn, template, assigns)
assignsを使ってtemplateを描画します。
render関数の使用例です。
defmodule MyAppWeb.UserController do
  use Phoenix.Controller
  def show(conn, _params) do
    render(conn, "show.html", message: "Hello")
  end
end
(2) Redirection - redirect(conn, opts)
与えられたURLへのredirect responseを送ります。
redirect(conn, to: "/login")
redirect(conn, external: "https://elixir-lang.org")
(3) Flash messages - put_flash(conn, key, message)
conn に Flash messages を保存します。
iex> conn = put_flash(conn, :info, "Welcome Back!")
iex >get_flash(conn, :info)
"Welcome Back!"
ちなみにuser_controller.exで保存されたFlash messagesはapp.html.heexの中で以下のように表示されています。
<main class="container">
  <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
  <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
  <%= @inner_content %>
</main>
4-2.View
defmodule PeopleWeb.UserView do
  use PeopleWeb, :view
end
5.templatesファイル
phx.gen.htmlは以下のtemplatesファイルも自動生成します。
Phoenixの基本的なことですが、templateがdataを受け取る方法は、conn の assigns mapを通してです。
"assigns" storageはplugs 間同士で値を共有するのに使われます。
- assign(conn, key, value) でconn のassignsに追加します。明示的に使われることはあまりないようですが。
assigns storage はmapです。
iex> conn.assigns[:hello]
nil
iex> conn = assign(conn, :hello, :world)
iex> conn.assigns[:hello]
:world
templateの中でassignsを参照するには @ ショートカットが用意されています。
render(conn, "show.html", username: "joe")
これはtemplateでは、以下のように参照されます。
<p>user: <%= @username %></p>
5-1.index
トップページです。user一覧を表示します。(4-1)のusersがこのtemplateで@usersで参照されています。
<h1>Listing Users</h1>
<table>
  <thead>
    <tr>
      <th>First name</th>
      <th>Last name</th>
      <th>Age</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
<%= for user <- @users do %>
    <tr>
      <td><%= user.first_name %></td>
      <td><%= user.last_name %></td>
      <td><%= user.age %></td>
      <td>
        <span><%= link "Show", to: Routes.user_path(@conn, :show, user) %></span>
        <span><%= link "Edit", to: Routes.user_path(@conn, :edit, user) %></span>
        <span><%= link "Delete", to: Routes.user_path(@conn, :delete, user), method: :delete, data: [confirm: "Are you sure?"] %></span>
      </td>
    </tr>
<% end %>
  </tbody>
</table>
<span><%= link "New User", to: Routes.user_path(@conn, :new) %></span>
user_path()は Path Helpers と呼ばれ、Routingのパスを生成してくれます。==> Routing / Path Helpers
ここでちょっとPath Helpersを試してみましょう。
$iex -S mix phx.server
iex(3)> alias PeopleWeb.Router.Helpers
PeopleWeb.Router.Helpers
iex(4)> Helpers.user_path(PeopleWeb.Endpoint, :index)
"/users"
iex(5)> Helpers.user_path(PeopleWeb.Endpoint, :edit, %People.Users.User{id: 1}) 
"/users/1/edit"
iex(6)> Helpers.user_path(PeopleWeb.Endpoint, :show, %People.Users.User{id: 1}) 
"/users/1"
iex(7)> Helpers.user_path(PeopleWeb.Endpoint, :delete, %People.Users.User{id: 1})
"/users/1"
5-2.show
個別のuserを表示します。
<h1>Show User</h1>
<ul>
  <li>
    <strong>First name:</strong>
    <%= @user.first_name %>
  </li>
  <li>
    <strong>Last name:</strong>
    <%= @user.last_name %>
  </li>
  <li>
    <strong>Age:</strong>
    <%= @user.age %>
  </li>
</ul>
<span><%= link "Edit", to: Routes.user_path(@conn, :edit, @user) %></span> |
<span><%= link "Back", to: Routes.user_path(@conn, :index) %></span>
5-3.new
user作成画面を表示します。
assignsには(4-2) (4-3)のchangesetが含まれている。assignsにaction:user_path(@conn, :create) を追加して、form.htmlをrenderします。
<h1>New User</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.user_path(@conn, :create)) %>
<span><%= link "Back", to: Routes.user_path(@conn, :index) %></span>
またここで入力された値はcontorollerのcreateの"user"に渡されます。
  def create(conn, %{"user" => user_params}) do
ちなみにassignsにactionを追加するにはMap.putを使っています。
Map.put(map, key, value)
Puts the given value under key in map
5-4.edit
user編集画面を表示します。
assignsには (4-5) (4-6) のchangesetが含まれている。assignsにaction:user_path(@conn, :update, @user) を追加して、form.htmlをrenderします。
<h1>Edit User</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.user_path(@conn, :update, @user)) %>
<span><%= link "Back", to: Routes.user_path(@conn, :index) %></span>
またここで入力された値はcontorollerのupdateの"user"に渡されます。
def update(conn, %{"id" => id, "user" => user_params}) do
5-5.form
newとeditの中身です。両者では渡されてくるactionの値だけが違います。
changesetがRepo.[insert,update]で作成された場合は、「Oops,...」の警告文を出します。
<.form let={f} for={@changeset} action={@action}>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>
  <%= label f, :first_name %>
  <%= text_input f, :first_name %>
  <%= error_tag f, :first_name %>
  <%= label f, :last_name %>
  <%= text_input f, :last_name %>
  <%= error_tag f, :last_name %>
  <%= label f, :age %>
  <%= number_input f, :age %>
  <%= error_tag f, :age %>
  <div>
    <%= submit "Save" %>
  </div>
</.form>
以下少し長くなりますが、formについての説明が続きます。
(1) Phoenix.HTML.Form について
(A) form_for(form_data, action, options \ [], fun)
- form builder と 無名関数からformタグを生成する関数
<%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %>
  Name: <%= text_input f, :name %>
<% end %>
(B) Phoenix.LiveView integration
しかしPhoenix.LiveViewとの統合でform_forは使わないよう案内されています。
<%= form_for @changeset, url, opts, fn f -> %>
  <%= text_input f, :name %>
<% end %>
Phoenix.LiveView.Helpers を importして以下のように書き換えるべき。
<.form let={f} for={@changeset}>
  <%= text_input f, :name %>
</.form>
(C) label(form, field)
- field のラベルタグを生成する関数
- form は Phoenix.HTML.Form (emitted by form_for) または atom
# Assuming form contains a User schema
label(form, :name, "Name")
#=> <label for="user_name">Name</label>
label(:user, :email, "Email")
#=> <label for="user_email">Email</label>
(D) text_input(form, field, opts \ [])
- text input を生成する関数
- form は Phoenix.HTML.Form (emitted by form_for) または atom
# Assuming form contains a User schema
text_input(form, :name)
#=> <input id="user_name" name="user[name]" type="text" value="">
text_input(:user, :name)
#=> <input id="user_name" name="user[name]" type="text" value="">
error_tagはドキュメントにはありませんでしたが、label や text_inputと同等と思われます。
(2) changeset.actionについて
changeset.actionにはchangesetを作成した"action"名が入るようです。
- changeset = Users.change_user(%User{}) の場合はchangeset.action = nil となります
- {:error, changeset} = Repo.insert(changeset) の場合はchangeset.action = insertとなります。
- {:error, changeset} = Repo.update(changeset) の場合はchangeset.action = updateとなります。
(3) Repo.insert のエラー時に作られるchangeset
またUserの新規作成時にlast_nameを空白のまま Repo.insert すると以下のようなchangesetが返されます。
#Ecto.Changeset<
  action: :insert,
  changes: %{age: 14, first_name: "taro"},
  errors: [last_name: {"can't be blank", [validation: :required]}],
  data: #People.Users.User<>,
  valid?: false
formの error_tag には changeset.errors の内容が表示されます。
他方、First name と Age の値は changeset.changes に保持され、それぞれのfieldに表示されます。
(4) new 画面に渡されるchangeset
changeset = Users.change_user(%User{}) で作られたchangesetが, new 画面に渡されますが、以下のような内容になります。
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [
    first_name: {"can't be blank", [validation: :required]},
    last_name: {"can't be blank", [validation: :required]},
    age: {"can't be blank", [validation: :required]}
  ],
  data: #People.Users.User<>,
  valid?: false
このchangesetはerrorsを含みますが、actionがnilなのでformにはエラー表示はありません。 ドキュメントの「A note on :errors」を参照してください。
今回は以上です。
■ Elixir/Phoenixの基礎についてまとめた過去記事
Elixir Ecto チュートリアル - Qiita
Elixir Ecto のまとめ - Qiita
Elixir Ecto Association - Qiita
Phoenix1.6の基本的な仕組み - Qiita
Phoenixのログイン管理のためのSessionの使い方 - Qiita
Phoenix1.3のUserアカウントとSession - Qiita
Phoenix1.3+Guardian1.0でJWT - Qiita
