技術メモ

プログラミングとか電子工作とか

Phoenix入門 (第13章 Contexts その2)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
引き続きContextsです。この分量だと3回に別れるかな・・・

In-context Relationships

Relationshipsということはようやくテーブルの関連付けなどでしょうか。

前回までに作成したAccountuserですが、メールアドレスなど登録情報が何もありません。(パスワードはありますが・・・)
そこで、ログイン用のメールアドレスを追加するのですが、これをuserテーブルに追加してしまうと外部認証(Googleアカウントとかでログインするやつ)に対応しようとすると大変なことになります。
なのでアカウント情報にメールアドレスのデータを紐付けて管理することにします。

Contextに追加する

そこでまたContextを作成していくのですが、前回作成したAccountにContextを追加していくようなことができるそうです。
phx.gen.htmlのようなコマンド、mix phx.gen.contextで追加していきます。

PS \hello> mix phx.gen.context Accounts Credential credentials email:string:unique user_id:references:users
You are generating into an existing context.
The Hello.Accounts context currently has 6 functions and 1 files in its directory.

  * It's OK to have multiple resources in the same context as     long as they are closely related
  * If they are not closely related, another context probably works better

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y
* creating lib/hello/accounts/credential.ex
* creating priv/repo/migrations/20190401004125_create_credentials.exs
* injecting lib/hello/accounts.ex
* injecting test/hello/accounts/accounts_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

今回はウェブ上でのCRUD操作は要らないのでmix phx.gen.contextで追加したようです。
(userのviewなどに機能を追加していく)
* injectinglib/hello/accounts.exファイルに追記されているのが分かります。

参照先が削除されたときの振る舞い(ON DELETE)

Credentialの情報(今回はメールアドレスだけ)はユーザーIDと1対1で関連付いているので、usersのデータが消去されたときには消えて欲しいです。
なのでその情報をmigrationsのテーブル追加スクリプトに追記します。
* creating priv/repo/migrations/20190401004125_create_credentials.exsを編集します(環境によってタイムスタンプは違う)。

defmodule Hello.Repo.Migrations.CreateCredentials do
  use Ecto.Migration

  def change do
    create table(:credentials) do
      add :email, :string
      add :user_id, references(:users, on_delete: :delete_all), null: false # :nothing を :delete_all に

      timestamps()
    end

    create unique_index(:credentials, [:email])
    create index(:credentials, [:user_id])
  end
end

, null: falseも追記されてるのは、ユーザーと紐付いてない謎の認証情報が発生しないようにということでしょう。
これで良さそうなのでマイグレーションします。

PS \hello> mix ecto.migrate
Compiling 2 files (.ex)
Generated hello app
[info] == Running 20190401004125 Hello.Repo.Migrations.CreateCredentials.change/0 forward
[info] create table credentials
[info] create index credentials_email_index
[info] create index credentials_user_id_index
[info] == Migrated 20190401004125 in 0.0s

これでデータベース上のテーブルなどは良さそうです。

Contextで扱うスキーマの関係性

次にPhoenix側での関係性も記載しておきます。Contextのlib/hello/accounts/user.exに対応関係を追記します。

defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.Credential # 忘れないで(戒め)

  schema "users" do
    field :name, :string
    field :username, :string
    has_one :credential, Credential # 追加

    timestamps()
  end
...

has_oneマクロはEctoの機能だそうで、他のスキーマとの関連性をEcto側で使える様にするものだそうです。
同様にcredential.exの方にも関連性を記載します。

defmodule Hello.Accounts.Credential do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.User # 忘れないで(戒め)

  schema "credentials" do
    field :email, :string
    belongs_to :user, User # 通常のフィールドからbelongs_toに変更

    timestamps()
  end
...

belongs_toマクロは先ほど同様Ectoのマクロで、Userに対して従属していることをEctoに知らせます。
この辺自動でやってくれないかなぁ。

preloadの設定

お次はgetなどの取得時にCredentialのデータを取っておくように書いておく設定をContextに書いておきます。
lib/hello/accounts.exを編集します。

...
  def list_users do
    User
    |> Repo.all()
    |> Repo.preload(:credential)
  end
...
  def get_user!(id) do
    User
    |> Repo.get!(id)
    |> Repo.preload(:credential)
  end
...

Repo.preload(:credential)を入れておくことで%Accounts.User{}の構造体の中にデータが載るようです。
あとなんか効率的にデータベースのデータが取得できる?かもしれないです。この辺はちょっとよく分かりませんでしたが、関連付いてて、十中八九使用するデータはpreloadしておくのが吉かもしれません。

入力のテンプレートに入力フォームを追加

ここまできてようやくウェブ上の方に機能を追加していきます。
まずは入力フォームにemailを記入できるようにlib/hello_web/templates/user/form.html.eexを編集します。

...
  <div class="form-group">
    <%= inputs_for f, :credential, fn cf -> %>
      <%= label cf, :email %>
      <%= text_input cf, :email %>
      <%= error_tag cf, :email %>
    <% end %>
  </div>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

分かりにくいですが、基本的に<%= ○○ %>を除けばElixirのコードです。
inputs_for関数?はf(ここではUserのフォーム)に関連付いた情報で:credentialの入力を作成する、という構文のようです。fn cf ->以降は他の入力フォームと同じです。
一応ソースに直すのであれば

inputs_for f, :credential, fn cf ->
  label cf, :email
  text_input cf, :email
  error_tag cf, :email
end

のようになってる感じです。各関数?マクロ?は入力のタグを追加するってところでしょうか。

出力のテンプレートに表示を追加

あとは表示を追加しておきましょう。
lib/hello_web/templates/user/show.html.eexを編集します。

<h1>Show User</h1>

<ul>

  <li>
    <strong>Name:</strong>
    <%= @user.name %>
  </li>

  <li>
    <strong>Username:</strong>
    <%= @user.username %>
  </li>

  <li>
    <strong>Email:</strong>
    <%= @user.credential.email %>
  </li>
...

このへんは楽勝ですね。
この時点でPhoenixは機能すると思います。一応mix phx.serverhttp://localhost:4000/users/newにアクセスしてみます。

f:id:ysmn_deus:20190401105646p:plain

('ω')。o(????????????)

UndefinedFunctionError at GET /users/new
function Credential.__struct__/0 is undefined (module Credential is not available)

Credentialが使えない、なんでや。
ちょっとソースを見直した結果、Contextのそれぞれ(lib/hello/accounts/user.excredential.ex)にエイリアスを書くのを忘れていた。アホス。
追記して再度アクセス。

f:id:ysmn_deus:20190401110102p:plain

ヨシ!

ContextのCreateとUpdateを変更(関連データのバリデーション)

とはいっても、現段階ではただ表示されているだけで実際に保存しようとすると

UndefinedFunctionError at GET /users/2
function nil.email/0 is undefined

になると思います。これはContextのupdate_userなどが古いままなのでメール情報を格納するステップが抜けてるので、「メール情報ないやんけ!」と怒られてるのでしょう。
この辺を追加します。Contextのlib/hello/accounts/accounts.exを編集します。

defmodule Hello.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Accounts.{User, Credential} # Accountsの後ろに.をつけ忘れてた
...
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
    |> Repo.insert()
  end
...
  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
    |> Repo.update()
  end
...
#  alias Hello.Accounts.Credential # 消してもヨシ、上で呼んでるので冗長
...

Repo.insertなどする前のパイプラインにEcto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)を追加しました。
Ecto.Changeset.cast_assoc/3はどうやら第一引数に大元の関連付いた構造体(今回だとユーザーの構造体)、第二引数に関連データを示すアトム(今回だと:credential、たぶんユーザー構造体に追加されたキーとして読み込んでる)、第三引数に関連データのchangesetを渡してバリデーションするといった流れでしょう。

これで行けるそうです。やってみます。

SyntaxError
lib/hello/accounts.ex:9: syntax error before: '{'

(╬´◓ω◔╬) 凡ミスです。alias Hello.Accounts{User, Credential}となっていたのでalias Hello.Accounts.{User, Credential}`に修正。

f:id:ysmn_deus:20190401112417p:plain

行けました。メールアドレスをブランクで保存しようとしても、きちんとバリデーションではじかれます。
もっときっちりするならCredentialでメールアドレスの形式でバリデーションしてもいいかもしれません。

もう一項目行きたかったんですがちょっと長くなってるのでこの辺で区切ります。