LoginSignup
6
5

More than 5 years have passed since last update.

Phoenixガイドラインを読んでみた(9) - Ecto -

Posted at

はじめに

前回は、Channelを見てきました。
今回はEcto、そうDBです。

外へ

多くのアプリケーションではデータストレージへのアクセスが必要とされています。ElixirではEctoを使ってデータアクセスを行うことが可能です。Ectoには、現在以下のデータベースへのアダプタが存在します。

  • PostgreSQL
  • MySQL
  • MSSQL
  • SQLite3
  • MongoDB

Phoenixでは、標準で、EctoとPostgreSQLのアダプタがセットされています。

以後、EctoとPostgrex(ElixirのPostgreSQLドライバ)がインストールされて、設定が完了しているとします。
Ectoでは、phoenix.gen.htmlを使ってリソースを生成することができます。ここでは、Userリソース(フィールドとしてname, email, bio, number_of_pets)を作ることにします。

$ mix phoenix.gen.html User users name:string email:string bio:string number_of_pets:integer
* creating web/controllers/user_controller.ex
* creating web/templates/user/edit.html.eex
* creating web/templates/user/form.html.eex
* creating web/templates/user/index.html.eex
* creating web/templates/user/new.html.eex
* creating web/templates/user/show.html.eex
* creating web/views/user_view.ex
* creating test/controllers/user_controller_test.exs
* creating priv/repo/migrations/20160428030023_create_user.exs
* creating web/models/user.ex
* creating test/models/user_test.exs

Add the resource to your browser scope in web/router.ex:

    resources "/users", UserController

Remember to update your repository by running migrations:

    $ mix ecto.migrate

migration, controller, controller test, model, model test, view, 複数のtemplate用のファイルが生成されました。

出力されたコメントに従って、web/router.exresources "/users", UserControllerを追加します。

同じく、コメントに従ってマイグレーションします(mix ecto.migrate)。データベースが見つからないよ的なエラーが出た場合は、mix ecto.createでデータベースを作成してくれます。
Mixは、MIX_ENV=環境名を指定しない限り開発環境と見なします。Ectoでは、Mixからこの環境を引き継いで、作成されるデータベース名に適当なsuffixを付与します。

データベースサーバに直接ログインてして、マイグレーションされたテーブルを見てみます。

$ sudo su - postgres
$ psql 
psql (9.5.2)
Type "help" for help.

postgres=# \connect hello_phoenix_dev
You are now connected to database "hello_phoenix_dev" as user "postgres".
hello_phoenix_dev=# \d
                List of relations
 Schema |       Name        |   Type   |  Owner   
--------+-------------------+----------+----------
 public | schema_migrations | table    | postgres
 public | users             | table    | postgres
 public | users_id_seq      | sequence | postgres
(3 rows)

hello_phoenix_dev=# \d users;
                                       Table "public.users"
     Column     |            Type             |                     Modifiers                      
----------------+-----------------------------+----------------------------------------------------
 id             | integer                     | not null default nextval('users_id_seq'::regclass)
 name           | character varying(255)      | 
 email          | character varying(255)      | 
 bio            | character varying(255)      | 
 number_of_pets | integer                     | 
 inserted_at    | timestamp without time zone | not null
 updated_at     | timestamp without time zone | not null
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)

phoenix.gen.htmlで生成せされたマイグレーション用のソースは、priv/repo/migrationsに展開されます。

defmodule HelloPhoenix.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string
      add :email, :string
      add :bio, :string
      add :number_of_pets, :integer

      timestamps
    end

  end
end

inserted_at, updated_atは、マイグレーションファイルのtimestamps/0関数で追加されています。また、idはマイグレーションはなくても、暗黙的にprimary keyとして追加されます。

リポジトリ

HelloPhoenix.Repoモジュール(lib/hello_phoenix/repo.ex)は、Phoenixアプリケーションにおけるデータベース接続を行う基本モジュールです。

defmodule HelloPhoenix.Repo do
  use Ecto.Repo, otp_app: :hello_phoenix
end

このモジュールの主な役割は二つです。一つ目は、Ecto.Repoからquery用の関数を引用することと、opt_app名をモジュールのアプリケーション名にセットすることです。
phoenix.newでアプリケーションを生成したとき、同様に設定も作成されます。設定は、config/dev.exsにあります。

. . .
# Configure your database
config :hello_phoenix, HelloPhoenix.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "hello_phoenix_dev",
  hostname: "localhost",

設定ファイルへの記載は、opt_appの名前と、Repoモジュールの指定から始まります。そして、アダプタにPostgresをセットし、ログインに必要な情報が記載されます。もちろん、これらは実際に接続するデータベースの設定に直接変更しても構いません。
同様の設定ファイルが、他の環境用(config/test.exsconfig/prod.secret.exs)にもあります。

モデル

Ectoのモデルは、いくつかの関数やスキーマのフィールドなどから構成されます。モデルには、他のモデルとの関連や、validation、changesetの取り扱いも定義されています。

先ほど定義されたuserのモデル(web/models/user.ex)は、以下のようになります。

defmodule HelloPhoenix.User do
  use HelloPhoenix.Web, :model

  schema "users" do
    field :name, :string
    field :email, :string
    field :bio, :string
    field :number_of_pets, :integer

    timestamps
  end

  @required_fields ~w(name email bio number_of_pets)
  @optional_fields ~w()

  @doc """
  Creates a changeset based on the `model` and `params`.

  If no params are provided, an invalid changeset is returned
  with no validation performed.
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
  end
end

ChangesetとValidation

Changesetは、アプリケーションで使用できるようにデータ加工を行うパイプラインです。この加工には、型キャスト、validataion、外部パラメータによる何らかのフィルタなどが含まれます。

デフォルトで定義されるchangesetは以下の通りです。

  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
  end

cast/4関数は、データを@required_fields@optional_fieldsに振り分けます。デフォルトでは、全フィールドがrequiredです。
現状の動作を少し確認してみます。

$ iex -S mix phoenix.server

HelloPhoenix.UserをUserに

iex(1)> alias HelloPhoenix.User

全て空データ。errorあり。valid?がfalseになる

iex(2)> changeset = User.changeset(%User{}, %{})
%Ecto.Changeset{action: nil, changes: %{}, constraints: [],
 errors: [name: "can't be blank", email: "can't be blank", bio: "can't be blank", number_of_pets: "can't be blank"],
 filters: %{},
 model: %HelloPhoenix.User{__meta__: #Ecto.Schema.Metadata<:built>, bio: nil, email: nil, id: nil, inserted_at: nil, name: nil, number_of_pets: nil, updated_at: nil},
 optional: [], opts: [],
 params: %{}, prepare: [], repo: nil, 
 required: [:name, :email, :bio, :number_of_pets],
 types: %{bio: :string, email: :string, id: :id, inserted_at: Ecto.DateTime, name: :string, number_of_pets: :integer, updated_at: Ecto.DateTime},
 valid?: false, validations: []}

required_fields(number_of_pets)が空データ。errorあり。valid?がfalseになる

iex(3)> changeset2 = User.changeset(%User{}, %{name: "名前", email: "hogehoge@hogehoge", bio: "RXバイオライダー"})
%Ecto.Changeset{action: nil,
 changes: %{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "名前"}, constraints: [],
 errors: [number_of_pets: "can't be blank"],
 filters: %{},
 model: %HelloPhoenix.User{__meta__: #Ecto.Schema.Metadata<:built>, bio: nil, email: nil, id: nil, inserted_at: nil, name: nil, number_of_pets: nil, updated_at: nil},
 optional: [], opts: [],
 params: %{"bio" => "RXバイオライダー", "email" => "hogehoge@hogehoge", "name" => "名前"}, prepare: [], repo: nil,
 required: [:name, :email, :bio, :number_of_pets],
 types: %{bio: :string, email: :string, id: :id, inserted_at: Ecto.DateTime, name: :string, number_of_pets: :integer, updated_at: Ecto.DateTime},
 valid?: false, validations: []}

全データあり。errorなし。valid?がtrueになる

iex(4)> changeset3 = User.changeset(%User{}, %{name: "名前", email: "hogehoge@hogehoge", bio: "RXバイオライダー", number_of_pets: 3})
%Ecto.Changeset{action: nil,
 changes: %{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "名前", number_of_pets: 3}, constraints: [],
 errors: [], filters: %{},
 model: %HelloPhoenix.User{__meta__: #Ecto.Schema.Metadata<:built>, bio: nil, email: nil, id: nil, inserted_at: nil, name: nil, number_of_pets: nil, updated_at: nil},
 optional: [], opts: [],
 params: %{"bio" => "RXバイオライダー", "email" => "hogehoge@hogehoge", "name" => "名前", "number_of_pets" => 3}, prepare: [], repo: nil,
 required: [:name, :email, :bio, :number_of_pets],
 types: %{bio: :string, email: :string, id: :id, inserted_at: Ecto.DateTime, name: :string, number_of_pets: :integer, updated_at: Ecto.DateTime},
 valid?: true, validations: []}

全データと定義外のパラメータあり。errorなし。valid?がtrueになる

iex(5)> changeset4 = User.changeset(%User{}, %{name: "名前", email: "hogehoge@hogehoge", bio: "RXバイオライダー", number_of_pets: 3, extra: "エクストラ"})
%Ecto.Changeset{action: nil,
 changes: %{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "名前", number_of_pets: 3}, constraints: [],
 errors: [], filters: %{},
 model: %HelloPhoenix.User{__meta__: #Ecto.Schema.Metadata<:built>, bio: nil, email: nil, id: nil, inserted_at: nil, name: nil, number_of_pets: nil, updated_at: nil},
 optional: [], opts: [],
 params: %{"bio" => "RXバイオライダー", "email" => "hogehoge@hogehoge", "extra" => "エクストラ", "name" => "名前", "number_of_pets" => 3}, prepare: [], repo: nil,
 required: [:name, :email, :bio, :number_of_pets],
 types: %{bio: :string, email: :string, id: :id, inserted_at: Ecto.DateTime, name: :string, number_of_pets: :integer, updated_at: Ecto.DateTime},
 valid?: true, validations: []}

changeset4からの一部変更。errorなし。valid?がtrueになる

iex(6)> changeset5 = User.changeset(changeset4, %{name: "あたらしい名前", number_of_pets: 1, extra: "hoge", extra2: "fuga"}) 
%Ecto.Changeset{action: nil,
 changes: %{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "あたらしい名前", number_of_pets: 1}, constraints: [],
 errors: [], filters: %{},
 model: %HelloPhoenix.User{__meta__: #Ecto.Schema.Metadata<:built>, bio: nil, email: nil, id: nil, inserted_at: nil, name: nil, number_of_pets: nil, updated_at: nil},
 optional: [], opts: [],
 params: %{"bio" => "RXバイオライダー", "email" => "hogehoge@hogehoge", "extra" => "hoge", "extra2" => "fuga", "name" => "あたらしい名前", "number_of_pets" => 1}, prepare: [], repo: nil,
 required: [:name, :email, :bio, :number_of_pets],
 types: %{bio: :string, email: :string, id: :id, inserted_at: Ecto.DateTime, name: :string, number_of_pets: :integer, updated_at: Ecto.DateTime},
 valid?: true, validations: []}

それぞれのchangeset (未定義のパラメータextra, extra2はchangesetから除去されている)

iex(7)> changeset.changes
%{}
iex(8)> changeset2.changes
%{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "名前"}
iex(9)> changeset3.changes
%{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "名前", number_of_pets: 3}
iex(10)> changeset4.changes
%{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "名前", number_of_pets: 3}
iex(11)> changeset5.changes
%{bio: "RXバイオライダー", email: "hogehoge@hogehoge", name: "あたらしい名前", number_of_pets: 1}

デフォルトのパイプラインに、別のvalidationを追加することもできます。
例えば、bioフィールドが最少2文字、最大140文字必要であり、メールアドレスに"@"が含まれていることをvalidateするためには、以下のようにパイプラインを記述できます。

  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> validate_length(:bio, min: 2)
    |> validate_length(:bio, max: 140)
    |> validate_format(:email, ~r/@/)
  end

Controller

ControllerでどのようにEctoを使用するかを見るために、先ほどmix phoenix.gen.htmlで作成されたHelloPhoenix.UserControllerを見てみます。

defmodule HelloPhoenix.UserController do
. . .
  alias HelloPhoenix.User

  plug :scrub_params, "user" when action in [:create, :update]
. . .
end

前にも出ていますが、alias HelloPhoenix.Userは、%HelloPhoenix.User{}と書く代わりに%User{}と書けるようにするための定義です。

plug :scrub_params, "user" when action in [:create, :update]にあるscrub_params/2は、指定されたたパラメータが存在することを保証し、空文字列をnilに変換するPlugです。
なお、when action in [:create, :update]はPipelineで使用できるGuardです。説明はこちらにありますが、connaction(atom)、controller(alias (UserController? 要確認) )を使用することができます。

indexアクション

  def index(conn, _params) do
    users = Repo.all(User)
    render(conn, "index.html", users: users)
  end

これは、DBから全ユーザを取得して、Templateindex.html.eexに渡す処理を行います。
全ユーザ取得は、Repo.all/1にモデル名(alias)を与えることで行われます。

なお、ここではChangesetは使いません。Changesetは、DBへのデータ挿入を保証するためのものであり、DBから取得したデータは正しいはずだからです。

newアクション

def new(conn, _params) do
  changeset = User.changeset(%User{})
  render(conn, "new.html", changeset: changeset)
end

このアクションではChangesetを使用します。基本的に、newアクションでは、パラメータを使わずに空のChangesetを使ってTemplate "new.html.eex"を表示します。
これは、この後のcreateアクションで、必須項目が入力されていないなどのエラーが発生したときも、"new.html.eex"を表示するからです(このときは、Changesetにユーザに入力した値がセットされています)。

createアクション

"new.html"でformがsubmitされると、createアクションが呼び出されます。

def create(conn, %{"user" => user_params}) do
  changeset = User.changeset(%User{}, user_params)

  case Repo.insert(changeset) do
    {:ok, _user} ->
      conn
      |> put_flash(:info, "User created successfully.")
      |> redirect(to: user_path(conn, :index))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

パターンマッチングによって"user"キーの値を使って、changesetを作成します。DBへのinsertが成功すれば、Flashメッセージをセットしてindexアクションにリダイレクトされます。失敗すれば、もう一度"new.html"が表示されます。

showアクション

def show(conn, %{"id" => id}) do
  user = Repo.get!(User, id)
  render(conn, "show.html", user: user)
end

Repo.get!でidに対応したユーザを取得し、"show.html.eex"Templateを表示します。indexアクションと同様に、ここではChangesetは使用しません。

editアクション

  def edit(conn, %{"id" => id}) do
    user = Repo.get!(User, id)
    changeset = User.changeset(user)
    render(conn, "edit.html", user: user, changeset: changeset)
  end

editは、shownewを足したような処理です。Repo.get!でデータを取得し、そのデータを編集するためTemplate"edit.html.eex"を表示します。

"edit.html"では、パラメータの表示にchangeset、ルーティング用のパスを構築するためにuserを使用しています。
"edit.html"は、次のupdateアクションで入力エラーが発生したときにも使用されます。このとき、changesetには、ユーザが入力したパラメータが入っているので、ユーザが入力した値がそのまま表示されることになります(userを使用してしまうと、DBに入っている値が参照されてしまう)。

updateアクション

  def update(conn, %{"id" => id, "user" => user_params}) do
    user = Repo.get!(User, id)
    changeset = User.changeset(user, user_params)

    case Repo.update(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User updated successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, changeset} ->
        render(conn, "edit.html", user: user, changeset: changeset)
    end
  end

updateアクションはcreateアクションとほぼ同じですが、Repo.insert/1の代わりにRepo.update/1を使用しています。
ちなみに、Repo.update/1では、値に変更が無い場合にはDBにデータを送らないといった処理を行っています。

deleteアクション

  def delete(conn, %{"id" => id}) do
    user = Repo.get!(User, id)

    # Here we use delete! (with a bang) because we expect
    # it to always work (and if it does not, it will raise).
    Repo.delete!(user)

    conn
    |> put_flash(:info, "User deleted successfully.")
    |> redirect(to: user_path(conn, :index))
  end

最初にRepo.get!はDB上のレコードidを取得するために行っています。
その後Repo.delete!によりレコードを削除します。なお、レコードidが無くてもエラーにならないように"!"付きのバージョンを使用しています。
削除後、Flashメッセージをセットし、indexアクションにリダイレクトします。

Data Relationship And Dependencies

ここで、単純な動画投稿サイトを作る例を考えてみます。上記のユーザ管理に追加して、投稿動画の管理も行います。
動画(Video)モデルは、以下のように作成するものとします。

$ mix phoenix.gen.model Video videos name:string approved_at:datetime description:text likes:integer views:integer user_id:references:users
* creating priv/repo/migrations/20160513062728_create_video.exs
* creating web/models/video.ex
* creating test/models/video_test.exs

$ mix ecto.migrate

なお、Migration用のスクリプト(priv/depo/migrations/....._create_video.exs)は、以下のように生成されています。

defmodule HelloPhoenix.Repo.Migrations.CreateVideo do
  use Ecto.Migration

  def change do
    create table(:videos) do
      add :name, :string
      add :approved_at, :datetime
      add :description, :text
      add :likes, :integer
      add :views, :integer
      add :user_id, references(:users, on_delete: :nothing)

      timestamps
    end
    create index(:videos, [:user_id])

  end
end

Relationshipを定義するために、たとえば、以下のようなAPIが用意されています。

Schema.has_many/3

親対子の関連が、1対多であることを表します。今回の例だと、ユーザ→→動画の関連です。

Schema.belongs_to/3

子対親の関連が、1対1であることを表します。今回の例だと、動画→ユーザの関連です。

Schema.has_one/3

親対子の関連が、1対1であることを表します。

この他に、embeds_manyembeds_oneがあります (それぞれのモデルが独立して存在するのではなく、完全に親子関係になっていることを表す)。
なお、多対多の関連の場合には、関連モデルを作る(A ← →→ C ←← → B)ようです。

Userモデル(wbeb/models/user.ex)に has_many関連を追加します。

  schema "users" do
    field :name, :string
    field :email, :string
    field :bio, :string
    field :number_of_pets, :integer

    has_many :videos, HelloPhoenix.Video
    timestamps
  end

Videoモデルは、生成時にuser_id:references:usersを指定していますので、自動的にbelongs_toがセットされています。
また、user_idをfieldに追加する必要はありませんが、@required_fieldsにuser_idは追加しておきます。

defmodule HelloPhoenix.Video do
  use HelloPhoenix.Web, :model

  schema "videos" do
    field :name, :string
    field :approved_at, Ecto.DateTime
    field :description, :string
    field :likes, :integer
    field :views, :integer
    belongs_to :user, HelloPhoenix.User

    timestamps
  end

  @required_fields ~w(name approved_at description likes views user_id)

web/controllers/user_controller.exも、この関連を反映するために修正を行います。

defmodule HelloPhoenix.UserController do
  ...
  def index(conn, _params) do
    users = User |> Repo.all |> Repo.preload [:videos]
    render(conn, "index.html", users: users)
  end

  def show(conn, %{"id" => id}) do
    user = User |> Repo.get!(id) |> Repo.preload [:videos]
    render(conn, "show.html", user: user)
  end
  ...
end

Repo.preload/2によって、いい感じでVideoモデルをロードしてくれます。

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5