Programming Phoenix勉強その10
Programming Phoenix勉強その10
その10です。 chapter7の続きです。
Ecto.Queryの利用
前のChapterで調べた Ecto.Query
を利用して Category
にソート用の query
と取得用の query
を生成できる関数を用意します。
def alphabetical(query) do
from c in query, order_by: c.name
end
def names_and_ids(query) do
from c in query, select: {c.name, c.id}
end
テンプレートの準備
カテゴリ一覧は取得できるようになったのでそれを表示できるようにしておきます。 video/form.html.eex
を以下のように編集します。
<%= form_for @changeset, @action, fn f -> %>
...
<!-- 追加 -->
<div class="form-group">
<%= label f, :category_id, "Category", class: "control-label" %>
<%= select f, :category_id, @categories, class: "form-control", prompt: "Choose a category" %>
</div>
...
<% end %>
video/new.html.eex
を以下のように編集します。
<h2>New video</h2>
<%= render "form.html", changeset: @changeset, categories: @categories,
action: video_path(@conn, :create) %>
<%= link "Back", to: video_path(@conn, :index) %>
video/edit.html.eex
を以下のように編集します。
<h2>Edit video</h2>
<%= render "form.html", changeset: @changeset, categories: @categories,
action: video_path(@conn, :update, @video) %>
<%= link "Back", to: video_path(@conn, :index) %>
QueryのAPIについて
Query
構築の際に使えるものは以下
==, !=, <=, >=,<,>
and, or, not
in
like,ilike
is_nil
count, avg, sum, min, max
datetime_add, date_add
fragment, field, type
より柔軟に Query
を使いたい場合は fragments
を使うことが出来る。
from(u in User, where: fragment("lower(username) = ?", ^String.downcase(uname)))
よくある静的プレースホルダと同じでしょうか。この方法でもセキュリティは担保されています。
もっと柔軟にクエリを投げたいときは以下のように直接SQLを実行できます。
iex> Ecto.Adapters.SQL.query(Rumbl.Repo, "SELECT power($1, $2)", [2, 10])
クエリで関連するものも取りたい時は以下
iex(6)> user = Repo.one from(u in User, limit: 1)
[debug] QUERY OK source="users" db=16.0ms decode=15.0ms
SELECT u0."id", u0."name", u0."username", u0."password_hash", u0."inserted_at", u0."updated_at" FROM "users" AS u0 LIMIT 1 []
%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
inserted_at: ~N[2017-01-11 03:37:33.878000], name: "aaa", password: nil,
password_hash: "$2b$12$L2IGA8kAewNvbOLJ0/c7i.4m6k18hAmuTSG4JuaHhyUK0qWfB0hae",
updated_at: ~N[2017-01-16 03:40:31.371000], username: "aaa",
videos: #Ecto.Association.NotLoaded<association :videos is not loaded>}
iex(7)> user.videos # この時点ではNotLoaded
#Ecto.Association.NotLoaded<association :videos is not loaded>
iex(8)> user = Repo.preload(user, :videos) # preloadすると関連するものも取れる
[debug] QUERY OK source="videos" db=78.0ms
SELECT v0."id", v0."url", v0."title", v0."description", v0."user_id", v0."category_id", v0."inserted_at", v0."updated_at", v0."user_id" FROM "videos" AS v0 WHERE (v0."user_id" = $1)
ORDER BY v0."user_id" [1]
%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
inserted_at: ~N[2017-01-11 03:37:33.878000], name: "aaa", password: nil,
password_hash: "$2b$12$L2IGA8kAewNvbOLJ0/c7i.4m6k18hAmuTSG4JuaHhyUK0qWfB0hae",
updated_at: ~N[2017-01-16 03:40:31.371000], username: "aaa", videos: []}
iex(9)> user.videos
[]
Repo.preload
関数を使えば関連するものも一緒に取得できます。ただ、毎回 user
の取得と preload
を別々にやるのは面倒なので以下のようなオプションが良いされてます。
iex(10)> user = Repo.one from(u in User, limit: 1, preload: [:videos])
[debug] QUERY OK source="users" db=0.0ms
SELECT u0."id", u0."name", u0."username", u0."password_hash", u0."inserted_at", u0."updated_at" FROM "users" AS u0 LIMIT 1 []
[debug] QUERY OK source="videos" db=16.0ms
SELECT v0."id", v0."url", v0."title", v0."description", v0."user_id", v0."category_id", v0."inserted_at", v0."updated_at", v0."user_id" FROM "videos" AS v0 WHERE (v0."user_id" = $1)
ORDER BY v0."user_id" [1]
%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
inserted_at: ~N[2017-01-11 03:37:33.878000], name: "aaa", password: nil,
password_hash: "$2b$12$L2IGA8kAewNvbOLJ0/c7i.4m6k18hAmuTSG4JuaHhyUK0qWfB0hae",
updated_at: ~N[2017-01-16 03:40:31.371000], username: "aaa", videos: []}
iex(11)>
join
も普通に出来ます。
iex(11)> Repo.all from u in User,
...(11)> join: v in assoc(u, :videos),
...(11)> join: c in assoc(v, :category),
...(11)> where: c.name == "Comedy",
...(11)> select: {u, v}
[debug] QUERY OK source="users" db=31.0ms
SELECT u0."id", u0."name", u0."username", u0."password_hash", u0."inserted_at", u0."updated_at", v1."id", v1."url", v1."title", v1."description", v1."user_id", v1."category_id", v1.
"inserted_at", v1."updated_at" FROM "users" AS u0 INNER JOIN "videos" AS v1 ON v1."user_id" = u0."id" INNER JOIN "categories" AS c2 ON c2."id" = v1."category_id" WHERE (c2."name" =
'Comedy') []
[]
iex(12)>
各制約について
現状のアプリケーションはマイグレーションファイルに create unique_index(:users, [:username])
とあり、重複するユーザーネームを登録しようとするとエラーになります。
このままだと画面にエラーが出てしまうので changeset
で受け取れるように変更してみます。 user.ex
を編集します。
def changeset(model, params \\ %{}) do
model
|> cast(params, [:name, :username]) # 更新予定のパラメータカラムを第三引数でとる(?)
|> validate_required([:name, :username]) # このリストがcastが返すchangesetに存在するか検証
|> validate_length(:username, min: 1, max: 20)
|> unique_constraint(:username)
end
unique_constraint
を最後のパイプラインに追加することで :username
がかぶっていればエラーにしてくれます。
この調子で外部キー制約もエラーハンドリングできるようにします。 video.ex
を以下のように変更します。 色々やった結果元の部分も間違っていたので修正してます。
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:url, :title, :description, :category_id])
|> validate_required([:url, :title, :description])
|> assoc_constraint(:category)
end
validate_required
の第三引数には何がはいるのだろうか・・・と思いましたが、 公式ドキュメント に書いてありました。 :message
を取り、エラーメッセージをカスタマイズできるっぽいです。
これで外部制約も確かめることが出来ます。
iex(1)> alias Rumbl.Repo
iex(2)> alias Rumbl.Video
iex(3)> alias Rumbl.Category
iex(4)> import Ecto.Query
iex(5)> video = Repo.one(from v in Video, limit: 1)
iex(6)> changeset = Video.changeset(video, %{category_id: 12345})
iex(7)> Repo.update changeset
[debug] QUERY OK db=0.0ms
begin []
[debug] QUERY ERROR db=46.0ms
UPDATE "videos" SET "category_id" = $1, "updated_at" = $2 WHERE "id" = $3 [12345, {{2017, 1, 23}, {15, 2, 49, 366000}}, 1]
[debug] QUERY OK db=0.0ms
rollback []
{:error,
#Ecto.Changeset<action: :update, changes: %{category_id: 12345},
errors: [category: {"does not exist", []}], data: #Rumbl.Video<>,
valid?: false>}
良さそうです。
また、削除するときには foreign_key_constraint
関数が使えます。これを使うとカテゴリが削除出来ない理由をユーザに示す事ができます。
iex> alias Rumbl.Repo
iex> alias Rumbl.Category
iex> alias Rumbl.Video
iex> import Ecto.Query
iex> import Ecto.Changeset
iex> category = Repo.get_by Category, name: "Drama"
iex> changeset = Ecto.Changeset.change(category)
iex> changeset = foreign_key_constraint(changeset, :videos, name: :videos_category_id_fkey, message: "still exist")
iex> Repo.delete changeset
[debug] QUERY ERROR db=312.0ms
DELETE FROM "categories" WHERE "id" = $1 [6]
{:error,
#Ecto.Changeset<action: :delete, changes: %{},
errors: [videos: {"still exist", []}], data: #Rumbl.Category<>,
valid?: false>}
video
のデータの中に既に Drama
カテゴリーのIDを参照しているものがあれば設定したエラーを出してくれます。ちなみにどっかで書いたかもしれませんが Ecto.Changeset.change
関数は構造体とかからチェンジセット作ってくれる関数です。 cast
やバリデーションを使いたくない時に使えるみたいです。( 参考 )
もう一つの選択肢として、マイグレーション時に参照先が削除された時どうするかの設定が書けるみたいです。前に作った add_category_id_to_video
を見てみます。
defmodule Rumbl.Repo.Migrations.AddCategoryIdToVideo do
use Ecto.Migration
def change do
alter table(:videos) do
add :category_id, references(:categories)
end
end
end
add :category_id, references(:categories)
の部分が肝です。 references(:categories)
には :on_delete
オプションが付けられるようです。
:nothing
:デフォルト値。何もしない:delete_all
:関連するものも一緒に削除する:nilify_all
:関連するものが削除されたときNULL
にする
まとめ
Query
のAPIを使うことでデータベースへの柔軟な問合せができる。*_constraints
を使うことで各制約のバリデーションを使える。
書籍の中には頻繁にデータベースでやることはデータベースの中でやるべきだとありました。また、全部に *_constraints
付けるのではなくクラッシュすべきところはクラッシュすべきとも書いてありました。ココらへんはElixirのLet's Crashの思想から来ているのかと思います。ユーザ側がどうにか出来る制約エラーの場合はカスタムエラーメッセージを出すと良いらしいです。(また英語力が・・・)