ElixirDesktopで作るスマホアプリ Part 6 CRUD画面とリレーションの作成

この記事はElixirアドベントカレンダー2024のシリーズ２、10日目の記事です

https://qiita.com/advent-calendar/2024/elixir

本記事では、スポットを分類するためのフォルダのCRUD画面とユーザーとのリレーションを組んでいきます

今回の作業ブランチを作成します

```
git checkout -b feature/folder_crud
```

# CRUD画面の作成

CRUD画面を作成するコマンドは以下になります
```
mix phx.gen.live [コンテキスト名] [スキーマ名] [テーブル名] [カラム名:データ型]...
```

次のコマンドを実行します
user has_many foldersの関連にしたいので、外部キー制約をつけるために

[外部キー名]:references:[テーブル名]をカラムとして追加します

```
mix phx.gen.live Folders Folder folders name:string user_id:references:users
```

実行すると以下のログがでて色々作成されているのがわかります

最後の方にルーティングの追加とマイグレーションの実行が指示されているのでこちらを行っていきます

```
* creating lib/trarecord_web/live/folder_live/show.ex
* creating lib/trarecord_web/live/folder_live/index.ex
* creating lib/trarecord_web/live/folder_live/form_component.ex
* creating lib/trarecord_web/live/folder_live/index.html.heex
* creating lib/trarecord_web/live/folder_live/show.html.heex
* creating test/trarecord_web/live/folder_live_test.exs
* creating lib/trarecord/folders/folder.ex
* creating priv/repo/migrations/20241214150303_create_folders.exs
* creating lib/trarecord/folders.ex
* injecting lib/trarecord/folders.ex
* creating test/trarecord/folders_test.exs
* injecting test/trarecord/folders_test.exs
* creating test/support/fixtures/folders_fixtures.ex
* injecting test/support/fixtures/folders_fixtures.ex

Add the live routes to your browser scope in lib/trarecord_web/router.ex:

    live "/folders", FolderLive.Index, :index
    live "/folders/new", FolderLive.Index, :new
    live "/folders/:id/edit", FolderLive.Index, :edit

    live "/folders/:id", FolderLive.Show, :show
    live "/folders/:id/show/edit", FolderLive.Show, :edit


Remember to update your repository by running migrations:

    $ mix ecto.migrate
```

```diff_elixir:lib/trarecord_web/router.ex:L64
  scope "/", TrarecordWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{TrarecordWeb.UserAuth, :ensure_authenticated}] do
      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
      live "/onboarding", OnboardingLive.Index, :index
      
+     live "/folders", FolderLive.Index, :index
+     live "/folders/new", FolderLive.Index, :new
+     live "/folders/:id/edit", FolderLive.Index, :edit
+ 
+     live "/folders/:id", FolderLive.Show, :show
+     live "/folders/:id/show/edit", FolderLive.Show, :edit  
    end
  end
```

```
mix ecto.migrate
```

# リレーションの設定

Ectoでデータを取得した際に user.foldersでアクセスできるように`has_many`をUserに定義します

```diff_elixir:lib/trarecord/accounts/user.ex:L5
  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :current_password, :string, virtual: true, redact: true
    field :confirmed_at, :utc_datetime

+   has_many :folders, Trarecord.Folders.Folder

    timestamps(type: :utc_datetime)
  end
```

Ectoでデータを取得した際にfolder.userでアクセスできるようにと, user_idを外部キーとして使えるようにbelong_toをFolderに定義します

```diff_elixir:lib/trarecord/folders/folder.ex:L5
  schema "folders" do
    field :name, :string
-   field :user_id, :id
+   belongs_to :user, Trarecord.Accounts.User

    timestamps(type: :utc_datetime)
  end
```

保存・更新時にフォームパラメータを構造体に値としていれられるようにするために、castのリストに:user_idを追加します

ついでにuser_idをバリデーションの必須項目として設定します

```diff_elixir:lib/trarecord/folders/folder.ex:L13
  def changeset(folder, attrs) do
    folder
-   |> cast(attrs, [:name])
-   |> validate_required([:name])
+   |> cast(attrs, [:name, :user_id])
+   |> validate_required([:name, :user_id])
  end
```

# 保存時にuser_idを渡す

フォルダを保存する際にuser_idが必要なのですが
フォームはlive_componentで、index.exはlive_viewでそれぞれsocket.assingsは独立しているためindex(live_view)からフォームコンポーネント(live_component)にuser_idを渡す必要があります

```diff_elixir:lib/trarecord_web/live/folder_live/index.html.heex:L32
<.modal :if={@live_action in [:new, :edit]} id="folder-modal" show on_cancel={JS.patch(~p"/folders")}>
  <.live_component
    module={TrarecordWeb.FolderLive.FormComponent}
    id={@folder.id || :new}
    title={@page_title}
    action={@live_action}
    folder={@folder}
+   user_id={@current_user.id}
    patch={~p"/folders"}
  />
</.modal>
```

渡された値はsocket.assignsに入っているので
パラメータに新たにuser_idを追加してフォルダの作成をするようにしています

_paramsのキーは文字列なのと、マップのキーは文字列とアトムの片方しか使えないので気をつけましょう

```diff_elixir:lib/trarecord_web/live/folder_live/form_component.ex:L66
  defp save_folder(socket, :new, folder_params) do
+   folder_params = Map.put(folder_params, "user_id", socket.assigns.user_id)

    case Folders.create_folder(folder_params) do
      {:ok, folder} ->
        notify_parent({:saved, folder})

        {:noreply,
         socket
         |> put_flash(:info, "Folder created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
```

## 認証時のリダイレクト先を変更

ナビゲーションがまだないのと、フォルダ一覧をトップページにしたいので変更します


```diff_elixir:lib/trarecord_web/controllers/page_controller.ex:L9
  defp redirect_to(conn, %User{}) do
-   redirect(conn, to: ~p"/users/settings")
+   redirect(conn, to: ~p"/folders")
  end
```

## フォルダ一覧を自身のものだけに変更

このままだと他人のフォルダまで一覧されてしまうので、ユーザーIDで絞り込みます

```diff_elixir:lib/trarecord/folders.ex:L20
  def list_folders do
    Repo.all(Folder)
  end

+ def list_folders(user_id) do
+   Folder
+   |> where([f], f.user_id == ^user_id)
+   |> Repo.all()
+ end
```


```diff_elixir:lib/trarecord_web/live/folder_live/index.ex:L7
  @impl true
  def mount(_params, _session, socket) do  
+   user = socket.assigns.current_user
-   {:ok, stream(socket, :folders, Folders.list_folders())}
+   {:ok, stream(socket, :folders, Folders.list_folders(user.id))}
  end
```

# 動作確認

ログイン済みのアカウントでフォルダを作ったあと、別のユーザーを作成して、他のユーザーのフォルダが表示されないのを確認できました

![996ffcd4ecb97140f2f9d731b8fa8968.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/5fb0e5e7-e600-5387-1530-685c443a435f.gif)



# テスト修正

ジェネレーターで生成されたテストに以下の修正を入れていきます
- folder_fixtureを folder factroyに置き換え
- ログイン後のリダイレクトさきをフォルダ一覧に変更

## ファクトリの追加

最初にFolderFactoryを作成します
`build(:user)`とすることで保存前の構造体を渡して、`insert(:folder)`時にユーザーを作成したうえでフォルダを作成してくれます

```test/support/factories/folder_factory.ex
defmodule Trarecord.FolderFactory do
  @moduledoc """
  Folder Factory for ExMachina
  """
  defmacro __using__(_opts) do
    alias Trarecord.Folders.Folder

    quote do
      def folder_factory do
        %Folder{
          name: "new folder",
          user: build(:user)
        }
      end
    end
  end
end
```

FolderFactoryを作成したのでFactoryで読み込みます

```diff_elixir:test/support/factory.ex
defmodule Trarecord.Factory do
  use ExMachina.Ecto, repo: Trarecord.Repo

  use Trarecord.UserFactory
+ use Trarecord.FolderFactory
end
```

## folders_test

folder_fixture()をinsert(:folder)に置き換えます

```diff_elixir:test/trarecord/folders_test.exs
defmodule Trarecord.FoldersTest do
  use Trarecord.DataCase

  alias Trarecord.Folders

  describe "folders" do
    alias Trarecord.Folders.Folder

-   import Trarecord.FoldersFixtures

    @invalid_attrs %{name: nil}

    test "list_folders/0 returns all folders" do
-     folder = folder_fixture()
+     folder = insert(:folder)
      assert Folders.list_folders() == [folder]
    end

    test "get_folder!/1 returns the folder with given id" do
-     folder = folder_fixture()
+     folder = insert(:folder)
      assert Folders.get_folder!(folder.id) == folder
    end

    test "create_folder/1 with valid data creates a folder" do
+     user = insert(:user)
-     valid_attrs = %{name: "some name"}
+     valid_attrs = %{name: "some name", user_id: user.id}
      assert {:ok, %Folder{} = folder} = Folders.create_folder(valid_attrs)
      assert folder.name == "some name"
    end

    test "create_folder/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Folders.create_folder(@invalid_attrs)
    end

    test "update_folder/2 with valid data updates the folder" do
-     folder = folder_fixture()
+     folder = insert(:folder)
      update_attrs = %{name: "some updated name"}

      assert {:ok, %Folder{} = folder} = Folders.update_folder(folder, update_attrs)
      assert folder.name == "some updated name"
    end

    test "update_folder/2 with invalid data returns error changeset" do
-     folder = folder_fixture()
+     folder = insert(:folder)
      assert {:error, %Ecto.Changeset{}} = Folders.update_folder(folder, @invalid_attrs)
      assert folder == Folders.get_folder!(folder.id)
    end

    test "delete_folder/1 deletes the folder" do
-     folder = folder_fixture()
+     folder = insert(:folder)
      assert {:ok, %Folder{}} = Folders.delete_folder(folder)
      assert_raise Ecto.NoResultsError, fn -> Folders.get_folder!(folder.id) end
    end

    test "change_folder/1 returns a folder changeset" do
-     folder = folder_fixture()
+     folder = insert(:folder)
      assert %Ecto.Changeset{} = Folders.change_folder(folder)
    end
  end
end
```

これでテストを実行すると以下のエラーになるので、値のアサーションではなく、idでのパターンマッチを行うか get_folderやlist_folder時にpreload(:user)でユーザーの情報も一緒に取得することで解消できます


```elixir
     Assertion with == failed
     code:  assert folder == Folders.get_folder!(folder.id)
     left:  %Trarecord.Folders.Folder{
              __meta__: #Ecto.Schema.Metadata<:loaded, "folders">,
              id: 7,
              inserted_at: ~U[2024-12-16 04:30:15Z],
              name: "new folder",
              updated_at: ~U[2024-12-16 04:30:15Z],
              user: #Trarecord.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 3664, email: "user6@example.com", confirmed_at: nil, folders: #Ecto.Association.NotLoaded<association :folders is not loaded>, inserted_at: ~U[2024-12-16 04:30:15Z], updated_at: ~U[2024-12-16 04:30:15Z], ...>,
              user_id: 3664
            }
     right: %Trarecord.Folders.Folder{
              __meta__: #Ecto.Schema.Metadata<:loaded, "folders">,
              id: 7,
              inserted_at: ~U[2024-12-16 04:30:15Z],
              name: "new folder",
              updated_at: ~U[2024-12-16 04:30:15Z],
              user: #Ecto.Association.NotLoaded<association :user is not loaded>,
              user_id: 3664
            }
     stacktrace:
       test/trarecord/folders_test.exs:46: (test)
```

今回はidや更新日時でのパターンマッチのアサーションに変更します

パターンマッチのアサーションは左辺が変数ではなく固定された値である必要があるので
`^`(ピン演算子）でidを固定します

ピン演算子を付ける場合はプリミティブな値である必要があるので、フォルダの取得時にパターンマッチでidだけの変数を作ります

```diff_elixir:test/trarecord/folders_test.exs:L11
    test "list_folders/0 returns all folders" do
-     folder = insert(:folder)    
+     %{id: id} = insert(:folder)
-     assert Folders.list_folders() == [folder]
+     assert [%{id: ^id}] = Folders.list_folders()
    end

    test "get_folder!/1 returns the folder with given id" do
-     folder = insert(:folder)
+     %{id: id} = insert(:folder)
-     assert Folders.get_folder!(folder.id) == folder
+     assert %{id: ^id} = Folders.get_folder!(id)
    end
```

更新のテストは更新されてないかのチェックなのでupdated_atでパターンマッチを行っています

```diff_elixir:test/trarecord/folders_test.exs:L43
    test "update_folder/2 with invalid data returns error changeset" do
-     folder = insert(:folder)
+     %{updated_at: update} = folder = insert(:folder)
      assert {:error, %Ecto.Changeset{}} = Folders.update_folder(folder, @invalid_attrs)
      
+     assert %{updated_at: ^update} = Folders.get_folder!(folder.id)
    end
```

## folder_live_test

live_viewテストはログインしている必要があるので、 setup :register_and_log_in_userでログインした状態でテストが開始されます

またcreate_folderでフォルダを作成した状態でテストを実行する場合は以下のようにパイプ演算子で繋がれて実行されるので、引数からuserをパターンマッチで取得してログインしたユーザーでフォルダを作成しています

```elixir
conn
|> register_and_login_user
|> create_folder
```


```diff_elixir:test/trarecord_web/live/folder_live_test.exs
deffmodule TrarecordWeb.FolderLiveTest do
  use TrarecordWeb.ConnCase

  import Phoenix.LiveViewTest
- import Trarecord.FoldersFixtures

  @create_attrs %{name: "some name"}
  @update_attrs %{name: "some updated name"}
  @invalid_attrs %{name: nil}

+ setup :register_and_log_in_user

- defp create_folder(_) do
+ defp create_folder(%{user: user}) do
-   folder = fixture_folder()
+   folder = insert(:folder, user: user)
    %{folder: folder}
  end
  ...
end
```

## user_session_controller_test

ログイン後にリダイレクトされる場所を/foldersに変更したので修正します

```diff_elixir:test/trarecord_web/controllers/user_session_controller_test.exs:L9
    test "logs the user in", %{conn: conn, user: user} do
      conn =
        post(conn, ~p"/users/log_in", %{
          "user" => %{"email" => user.email, "password" => valid_user_password()}
        })

      assert get_session(conn, :user_token)
      assert redirected_to(conn) == ~p"/"

      # Now do a logged in request and assert on the menu
      conn = get(conn, ~p"/")
      response = html_response(conn, 302)
-     assert response =~ "/users/settings"
+     assert response =~ "/folders"
    end
```

## user_registration_live_test

ログイン後にリダイレクトされる場所を/foldersに変更したので修正します

```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs
    test "creates account and logs the user in", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")

      email = unique_user_email()
      form = form(lv, "#registration_form", user: params_for(:user_form_data, email: email))
      render_submit(form)
      conn = follow_trigger_action(form, conn)

      assert redirected_to(conn) == ~p"/onboarding"

      # Now do a logged in request and assert on the menu
      conn = get(conn, "/")
      response = html_response(conn, 302)
-     assert response =~ "/users/settings"
+     assert response =~ "/folders"
    end
```

![スクリーンショット 2024-12-16 14.45.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/e08334ad-d8ed-ee45-0dff-46395e00bc90.png)

CIも無事パスしたのでマージして完了です

# 最後に

今回はCRUD画面とリレーションの作成を行いました

関連付けのカラムをたしてもリレーションの設定はやってはくれませんが、変更箇所はそこまで多くないのでなんとかなるでしょう

次はデザインの修正とナビゲーションコンポーネントを実装していきます

本記事は以上になりますありがとうございました