はじめに
前回は、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.ex
にresources "/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.exs
やconfig/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です。説明はこちらにありますが、conn
、action
(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
は、show
とnew
を足したような処理です。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_many
、embeds_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モデルをロードしてくれます。