(この記事は、「fukuoka.ex(その2) Elixir Advent Calendar 2017」の4日目、および「Data Platform Advent Calendar 2017」の2日目です)
はじめに
Elixirで実際にプロダクト開発した経験からサンプルコードを交えて解説する本連載
今回は前回に引き続き、EctoでSQLのビルドを行わずに任意のSQLを実行する実装です。
今回は、バッチなどので想定される一括更新処理を実装してみます。
今回は以下の実装を例に考えます
- Phoenixでプロジェクトモデルを実装する
- プロジェクトに紐づくタスクモデルを実装する
- プロジェクト 1:多 タスク の関係である
- プロジェクトがキャンセルされた場合、プロジェクトのステータスをキャンセルにすると共に、タスクのステータスを全てキャンセルにする処理を実装する
また、以下の環境で実装しました
- Elixir v1.6.1
- Phoenix v1.3.2
- Ecto v2.2.10
- PostgreSQL v10.2
昨日は、@piacere_ex さんの「Excelから関数型言語マスター番外編:なぜ関数型言語は習得できないか?」でした
本連載の記事はこちら
|> ElixirでSI開発入門 #1 Ectoで悲観的ロック
|> ElixirでSI開発入門 #2 Ectoで楽観的ロック
|> ElixirでSI開発入門 #3 主キーが"id "じゃない既存DBへの接続
|> [ElixirでSI開発入門 #4 本番パスワードを環境変数に持たせる]
(https://qiita.com/tuchiro/items/4ccba7e210c596c383af)
|> ElixirでSI開発入門 #5 Ectoで自由にSQLを書いて実行する(参照編)
|> ElixirでSI開発入門 #6 Ectoで自由にSQLを書いて実行する(更新編)
お礼:各種ランキングに43回のランクインを達成しました
4/27~5/19までの23日間、毎日お届けした「季節外れのfukuoka.ex Elixir Advent Calendar」 は、Qiitaトップページトレンドランキングに4回、「はてなブックマーク」のホットエントリー「テクノロジー」カテゴリに2回もランクインし、他ランキングも含めると、トータル43回ものランクインを果たしました
Qiita「いいね」数は合計349件もいただき、fukuoka.exアドバイザーズとfukuoka.exキャストの一同、みなさまの暖かい応援に励まされていますので、引き続き、「季節外れのfukuoka.ex(その2) Elixir Advent Calendar」でも応援お願いします
開発手順
プロジェクト〜モデルの作成
プロジェクトは前回の記事で作成したecto_adapters_sampleを使用します。
モデルを追加作成します。
> mix phx.gen.html Projects Project projects title content status
> mix phx.gen.html Projects Task tasks project_id:references:projects title content status
ルーティングを追加します。
defmodule EctoAdaptersSampleWeb.Router do
use EctoAdaptersSampleWeb, :router
〜中略〜
scope "/", EctoAdaptersSampleWeb do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
resources "/items", ItemController
resources "/item_prices", ItemPriceController
resources "/projects", ProjectController # <-- ルーティングを追加
resources "/tasks", TaskController # <-- ルーティングを追加
end
〜中略〜
end
マイグレーションします。
> mix ecto.migrate
プロジェクトのステータスの定数定義をマップとして追加します。
ステータスは初期値を設定し、必須項目から除外します。
defmodule EctoAdaptersSample.Projects.Project do
use Ecto.Schema
import Ecto.Changeset
schema "projects" do
field :content, :string
field :status, :string, default: "new" # <-- 初期ステータスは新規
field :title, :string
timestamps()
end
@doc false
def changeset(project, attrs) do
project
|> cast(attrs, [:title, :content, :status])
|> validate_required([:title, :content]) # <-- ステータスを必須項目から削除
end
def status() do
%{
new: "new", #新規
working: "working", #作業中
completed: "completed", #完了
canceled: "canceled", #キャンセル
}
end
end
同じく、タスクのステータスも追加します。
defmodule EctoAdaptersSample.Projects.Task do
use Ecto.Schema
import Ecto.Changeset
schema "tasks" do
field :content, :string
field :status, :string, default: "new" # <-- 初期ステータスは新規
field :title, :string
field :project_id, :id
timestamps()
end
@doc false
def changeset(task, attrs) do
task
|> cast(attrs, [:title, :content, :status, :project_id])
|> validate_required([:title, :content]) # <-- ステータスを必須項目から削除
end
def status() do
%{
new: "new", #新規
working: "working", #作業中
completed: "completed", #完了
canceled: "canceled", #キャンセル
}
end
end
iexを起動し、プロジェクトとタスクを登録します。
iex(1)> alias EctoAdaptersSample.Projects
iex(2)> Projects.create_project(%{title: "Elixir Advent Calendar 2017", content: "Elixirに関する記事を続々投稿します"})
iex(3)> Projects.create_task(%{project_id: 1, title: "Phoenixプロジェクト作成", content: "mix phx.newする"})
iex(4)> Projects.create_task(%{project_id: 1, title: "モデルを作成", content: "mix phx.gen/htmlする"})
iex(5)> Projects.create_task(%{project_id: 1, title: "キャンセル実装", content: "キャンセル処理を実装する"})
iex(6)> Projects.create_task(%{project_id: 1, title: "記事におこす", content: "Qiitaの記事をかく"})
キャンセル処理をEcto.MultiとEcto.Adapters.SQLでキャンセル処理を実装
defmodule EctoAdaptersSample.Projects do
@moduledoc """
The Projects context.
"""
import Ecto.Query, warn: false
alias EctoAdaptersSample.Repo
alias EctoAdaptersSample.Projects.Task
alias EctoAdaptersSample.Utils.EctoUtil
alias Ecto.Multi
〜中略〜
def cancel_project(project_id) do
Multi.new
|> Multi.run(:cancel_project_and_tasks, EctoAdaptersSample.Projects, :cancel_project_and_tasks, [project_id])
|> Repo.transaction()
end
def cancel_project_and_tasks(%{}, project_id) do
project = get_project!(project_id)
{:ok, project} = update_project(project, %{status: Project.status.canceled})
sql = "update tasks set status = 'canceled' where project_id = $1"
result = EctoAdaptersSample.Utils.EctoUtil.query(EctoAdaptersSample.Repo, sql, [project_id])
{:ok, result}
end
※ Ecto.Multiに関しては「 ElixirでSI開発入門 #1 Ectoで悲観的ロック」参照
※ Ecto.Adapters.SQLに関しては前回記事「ElixirでSI開発入門 #5 Ectoで自由にSQLを書いて実行する」参照
EctoAdaptersSample.Utils.EctoUtil.query の改修
前回実装したEctoAdaptersSample.Utils.EctoUtil.queryを使いますが、今回のような更新処理ではエラーとなります。
原因は戻りをmap化する処理で使用したcolumns,rowsが更新SQLの場合nilとなることです。
こちらに対応するようにEctoUtilを修正します。
columnsがnilの場合はmapに更新件数num_rowsだけputして返します。
defmodule EctoAdaptersSample.Utils.EctoUtil do
@moduledoc """
Ecto関連の操作機能
"""
import Ecto.Query, warn: false
@doc """
SQL直接実行
## information
EctoのSQLビルダを使用せずにSQLを直接実行する
Ecto.Adapters.SQL.query()のWrapper関数
戻り値を[%{row1},%{row2}...]形式で返す。
## Examples
iex> EctoUtil.query(MyApp.Repo, "select * from users", params)
[${id: => 1, name: => "userA name" },${id: => 2, name: => "userB name" }]
"""
@spec query(Repo, string, [list]) :: [list]
def query(repo, sql, params) do
Ecto.Adapters.SQL.query(repo, sql, params)
|> result_to_map_list()
end
defp result_to_map_list({:ok, result}) do
columns = result.columns
case columns do
nil ->
[num_rows: result.num_rows]
_ ->
rows = result.rows
list_maps = Enum.map(rows, fn row -> row_columns_to_map(row, columns) end)
end
end
defp row_columns_to_map(row, columns) do
map_result =
Enum.map(Enum.with_index(row, 0), fn {k, i} -> [Enum.at(columns, i), k] end)
|> Enum.map(fn [a, b] -> {String.to_atom(a), b} end)
|> Map.new()
end
end
実行してみる
iexで作成した関数を実行します。
iex(7)> Projects.cancel_project(1)
全てのデータが同一トランザクション上で更新されているます。
※ tasksの更新に失敗した場合は、projectsのステータス更新もRallbackされ不整合が起こらない
まとめ
- Ecto.Adapters.SQLはselecti以外の更新クエリでも有効
- 更新系SQLの場合、Ecto.Adapters.SQLの戻りには、更新件数のみが格納される。
- Ecto.Multi.runで呼び出す関数内でもEcto.Adapters.SQLは使用可能
今回の例ではSQLの構造が非常にシンプルな為ピンとこない方もいるかもしれませんが、
複数のテーブルをJOINして一括更新をかけたい場合など、Ecto.MultiとEcto.Adapters.SQLの組み合わせは非常に強力です。
Ectoのクエリビルドで詰まった場合や更新性能に問題がでた場合はぜひ思い出してください。
明日は、@twinbee さんの「Elixirから簡単にRustを呼び出せるRustler#4 SHIFTJISを変換する」です
★★★ 満員御礼!Elixir MeetUpを6月末に開催します ★★★
※応募多数により、増枠しました
「fukuoka.ex#11:DB/データサイエンスにコネクトするElixir」を6/22(金)19時に開催します
私もこの連載で取り上げてない、開発ネタを出す予定ですのでぜひ奮ってご応募ください。