はじめに
この記事は、Elixir Advent Calendar 2022 の 20日目の投稿です。
前日は @a_utsuki さんの Elixirでつくりたいもの です。
業務ほぼRailsの為、すこしやっては忘れ去るLiveView。以前Apiからの操作で苦労したものを、DBのデーターでやってみようという趣旨です。
前提
erlang 25.2
elixir 1.14.2-otp-25
phoenix 1.6.15
postgres 14.1
アプリの作成
早速作っていきましょう。
kimny@kimuratomoakinoMacBook my_work % mix archive.install hex phx_new
Could not find Hex, which is needed to build dependency :phx_new
Shall I install Hex? (if running non-interactively, use "mix local.hex --force") [Yn] Y
* creating /Users/kimny/.asdf/installs/elixir/1.14.2-otp-25/.mix/archives/hex-2.0.0
Resolving Hex dependencies...
Resolution completed in 0.06s
New:
phx_new 1.6.15
* Getting phx_new (Hex package)
All dependencies are up to date
Compiling 11 files (.ex)
Generated phx_new app
(略)
* creating live_test/assets/js/app.js
* creating live_test/priv/static/robots.txt
* creating live_test/priv/static/images/phoenix.png
* creating live_test/priv/static/favicon.ico
Fetch and install dependencies? [Yn] Y
* running mix deps.get
We are almost there! The following steps are missing:
$ cd live_test
Then configure your database in config/dev.exs and run:
$ mix ecto.create
Start your Phoenix app with:
$ mix phx.server
You can also run your app inside IEx (Interactive Elixir) as:
$ iex -S mix phx.server
ガイドが出てますので、そのとおりに進めていきましょう。
% mix ecto.create
13:34:15.435 [info] Compiling file system watcher for Mac...
13:34:18.906 [info] Done.
==> file_system
Compiling 7 files (.ex)
Generated file_system app
==> connection
Compiling 1 file (.ex)
Generated connection app
==> decimal
Compiling 4 files (.ex)
Generated decimal app
==> mime
Compiling 1 file (.ex)
Generated mime app
==> live_test
Could not find "rebar3", which is needed to build dependency :telemetry
I can install a local copy which is just used by Mix
略
(mix 1.14.2) lib/mix/cli.ex:84: Mix.CLI.run_task/2
(elixir 1.14.2) src/elixir_compiler.erl:66: :elixir_compiler.dispatch/4
(elixir 1.14.2) src/elixir_compiler.erl:51: :elixir_compiler.compile/3
==> live_test
Compiling 14 files (.ex)
Generated live_test app
The database for LiveTest.Repo has been created
% mix phx.server
[info] Running LiveTestWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000 (http)
[info] Access LiveTestWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes...
できました。
一旦 ctrl
+ c
で落とします。
モデル
続いてモデルを作成します。
Place
モデルには、 :name, :latitude, :longitude
のattributeを作っておきます。
Phoenix
版の rails g scaffold
的なやつで作ります。
% mix phx.gen.html Prefectures Place places name latitude:float longitude:float
* creating lib/live_test_web/controllers/place_controller.ex
* creating lib/live_test_web/templates/place/edit.html.heex
* creating lib/live_test_web/templates/place/form.html.heex
* creating lib/live_test_web/templates/place/index.html.heex
* creating lib/live_test_web/templates/place/new.html.heex
* creating lib/live_test_web/templates/place/show.html.heex
* creating lib/live_test_web/views/place_view.ex
* creating test/live_test_web/controllers/place_controller_test.exs
* creating lib/live_test/prefectures/place.ex
* creating priv/repo/migrations/20221220093711_create_places.exs
* creating lib/live_test/prefectures.ex
* injecting lib/live_test/prefectures.ex
* creating test/live_test/prefectures_test.exs
* injecting test/live_test/prefectures_test.exs
* creating test/support/fixtures/prefectures_fixtures.ex
* injecting test/support/fixtures/prefectures_fixtures.ex
Add the resource to your browser scope in lib/live_test_web/router.ex:
resources "/places", PlaceController
Remember to update your repository by running migrations:
$ mix ecto.migrate
マイグレーションを通します。
% mix ecto.migrate
warning: the :gettext compiler is no longer required in your mix.exs.
Please find the following line in your mix.exs and remove the :gettext entry:
compilers: [..., :gettext, ...] ++ Mix.compilers(),
(gettext 0.20.0) lib/mix/tasks/compile.gettext.ex:5: Mix.Tasks.Compile.Gettext.run/1
(mix 1.14.2) lib/mix/task.ex:421: anonymous fn/3 in Mix.Task.run_task/4
(mix 1.14.2) lib/mix/tasks/compile.all.ex:92: Mix.Tasks.Compile.All.run_compiler/2
(mix 1.14.2) lib/mix/tasks/compile.all.ex:72: Mix.Tasks.Compile.All.compile/4
(mix 1.14.2) lib/mix/tasks/compile.all.ex:59: Mix.Tasks.Compile.All.with_logger_app/2
(mix 1.14.2) lib/mix/tasks/compile.all.ex:33: Mix.Tasks.Compile.All.run/1
02:21:14.515 [info] == Running 20221220093711 LiveTest.Repo.Migrations.CreatePlaces.change/0 forward
02:21:14.517 [info] create table places
02:21:14.522 [info] == Migrated 20221220093711 in 0.0s
warningが出る?
何やら warning
出てますね。
mix.exs
にある、
compilers: [..., :gettext, ...] ++ Mix.compilers(),
この記述を消せというようです。
ファイルはプロジェクト直下にあります。
defmodule LiveTest.MixProject do
use Mix.Project
def project do
[
app: :live_test,
version: "0.1.0",
elixir: "~> 1.12",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [..., :gettext, ...] ++ Mix.compilers(),#←これを消しておきましょう。
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
end
(略)
下記のようになればOKです。
defmodule LiveTest.MixProject do
use Mix.Project
def project do
[
app: :live_test,
version: "0.1.0",
elixir: "~> 1.12",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
end
(略)
:gettext
自体は必要なので、ライブラリから消さないようにとの事。
バージョンが上がって設定の書き方が変わっているようです。
ダミーデーター
では、次に seed
を書いてみましょう。
alias LiveTest.Prefectures.Place
alias LiveTest.Repo
data = [
["東京駅", 33.8569343, 30.7466767],
["梅田駅", 34.7031952, 135.4276025],
["天神駅", 33.5913809, 130.3288387]
]
data
|> Enum.map(fn [name, latitude, longitude] -> %Place{name: name, latitude: latitude, longitude: longitude} end)
|> Enum.map(fn (place) -> Repo.insert!(place) end)
とりあえず3件程。必要な alias
も書いておきます。
下記のようになればOKです。
ファイルを開くとコメントがありますが、コメントの下にでも追記しましょう。
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# LiveTest.Repo.insert!(%LiveTest.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias LiveTest.Prefectures.Place
alias LiveTest.Repo
data = [
["東京駅", 33.8569343, 30.7466767],
["梅田駅", 34.7031952, 135.4276025],
["天神駅", 33.5913809, 130.3288387]
]
data
|> Enum.map(fn [name, latitude, longitude] -> %Place{name: name, latitude: latitude, longitude: longitude} end)
|> Enum.map(fn (place) -> Repo.insert!(place) end)
コメント部分には、seedの流し方
mix run priv/repo/seeds.exs
が書かれていますので、実行します。
% mix run priv/repo/seeds.exs
[debug] QUERY OK db=2.2ms decode=1.2ms queue=0.3ms idle=25.4ms
INSERT INTO "places" ("latitude","longitude","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [33.8569343, 30.7466767, "東京駅", ~N[2022-12-20 18:30:55], ~N[2022-12-20 18:30:55]]
↳ Enum."-map/2-lists^map/1-0-"/2, at: lib/enum.ex:1658
[debug] QUERY OK db=0.3ms queue=0.4ms idle=36.5ms
INSERT INTO "places" ("latitude","longitude","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [34.7031952, 135.4276025, "梅田駅", ~N[2022-12-20 18:30:55], ~N[2022-12-20 18:30:55]]
↳ Enum."-map/2-lists^map/1-0-"/2, at: lib/enum.ex:1658
[debug] QUERY OK db=0.2ms queue=0.2ms idle=37.3ms
INSERT INTO "places" ("latitude","longitude","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [33.5913809, 130.3288387, "天神駅", ~N[2022-12-20 18:30:55], ~N[2022-12-20 18:30:55]]
↳ Enum."-map/2-lists^map/1-0-"/2, at: lib/enum.ex:1658
登録できました。
iexで確認したいので、一旦 iex
用の alias
も書いておきます。
プロジェクト直下に.iex.exs
を新規作成します。
alias LiveTest.Repo
alias LiveTest.Prefectures
alias LiveTest.Prefectures.Place
こんな感じでOKです。
では、 iex
をPhoenixから起動しましょう。 -S mix
を付けます。
% iex -S mix
Erlang/OTP 25 [erts-13.1.3] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]
Interactive Elixir (1.14.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
起動しました。下記を試しておきます。
iex(1)> Place |> Repo.all
[debug] QUERY OK source="places" db=7.9ms decode=1.3ms queue=0.7ms idle=1601.5ms
SELECT p0."id", p0."latitude", p0."longitude", p0."name", p0."inserted_at", p0."updated_at" FROM "places" AS p0 []
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
[
%LiveTest.Prefectures.Place{
__meta__: #Ecto.Schema.Metadata<:loaded, "places">,
id: 1,
latitude: 33.8569343,
longitude: 30.7466767,
name: "東京駅",
inserted_at: ~N[2022-12-20 18:50:03],
updated_at: ~N[2022-12-20 18:50:03]
},
%LiveTest.Prefectures.Place{
__meta__: #Ecto.Schema.Metadata<:loaded, "places">,
id: 2,
latitude: 34.7031952,
longitude: 135.4276025,
name: "梅田駅",
inserted_at: ~N[2022-12-20 18:50:03],
updated_at: ~N[2022-12-20 18:50:03]
},
%LiveTest.Prefectures.Place{
__meta__: #Ecto.Schema.Metadata<:loaded, "places">,
id: 3,
latitude: 33.5913809,
longitude: 130.3288387,
name: "天神駅",
inserted_at: ~N[2022-12-20 18:50:03],
updated_at: ~N[2022-12-20 18:50:03]
}
]
iex(2)> Place |> Repo.get(2)
[debug] QUERY OK source="places" db=0.4ms queue=0.5ms idle=1496.1ms
SELECT p0."id", p0."latitude", p0."longitude", p0."name", p0."inserted_at", p0."updated_at" FROM "places" AS p0 WHERE (p0."id" = $1) [2]
↳ anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
%LiveTest.Prefectures.Place{
__meta__: #Ecto.Schema.Metadata<:loaded, "places">,
id: 2,
latitude: 34.7031952,
longitude: 135.4276025,
name: "梅田駅",
inserted_at: ~N[2022-12-20 18:50:03],
updated_at: ~N[2022-12-20 18:50:03]
}
ルーティング
作られたページの為のルーティングを書き換えます。
今回は、 gen.html
で作っていますので、 resources
のルーティングを書く事と、トップページの設定を行います。
defmodule LiveTestWeb.Router do
use LiveTestWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {LiveTestWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", LiveTestWeb do
pipe_through :browser
get "/", PageController, :index
end
(略)
このようになっていますので、
scope "/", LiveTestWeb do
end
このブロック内を以下のように書き換えます。
scope "/", LiveTestWeb do
pipe_through :browser
get "/", PlaceController, :index
resources "/places", PlaceController
end
全体では、以下のようになります。
defmodule LiveTestWeb.Router do
use LiveTestWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {LiveTestWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", LiveTestWeb do
pipe_through :browser
get "/", PlaceController, :index
resources "/places", PlaceController
end
(略)
では、サーバーを起動しましょう。 iex
を使う可能性がある場合は、
iex -S mix phx.server
で、ブラウザと iex 間でシームレスに実験が出来ます。
では、 localhost:4000
を見てみましょう。
いい感じです。
一通り遷移できたら一度落としましょう。
LiveViewへの移行
では、LiveViewの導入に入ります。
今回は、DBからのデーターをLiveViewで表示し、簡単なイベントを仕込んでみたいと思います。
モデル作成時のコマンドを改造します。
mix phx.gen.live Prefectures Place places name latitude:float longitude:float
% mix phx.gen.live Prefectures Place places name latitude:float longitude:float
You are generating into an existing context.
The LiveTest.Prefectures context currently has 6 functions and 1 file in its directory.
* It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart
* If they are not closely related, another context probably works better
The fact two entities are related in the database does not mean they belong to the same context.
If you are not sure, prefer creating a new context over adding to the existing one.
Would you like to proceed? [Yn] Y
The following files conflict with new files to be generated:
* lib/live_test_web/live/place_live/show.ex
* lib/live_test_web/live/place_live/index.ex
* lib/live_test_web/live/place_live/form_component.ex
* lib/live_test_web/live/place_live/form_component.html.heex
* lib/live_test_web/live/place_live/index.html.heex
* lib/live_test_web/live/place_live/show.html.heex
* test/live_test_web/live/place_live_test.exs
* lib/live_test/prefectures/place.ex
See the --web option to namespace similarly named resources
Proceed with interactive overwrite? [Yn] Y
* creating lib/live_test_web/live/place_live/show.ex
* creating lib/live_test_web/live/place_live/index.ex
* creating lib/live_test_web/live/place_live/form_component.ex
* creating lib/live_test_web/live/place_live/form_component.html.heex
* creating lib/live_test_web/live/place_live/index.html.heex
* creating lib/live_test_web/live/place_live/show.html.heex
* creating test/live_test_web/live/place_live_test.exs
* creating lib/live_test/prefectures/place.ex
* creating priv/repo/migrations/20221220193931_create_places.exs
Add the live routes to your browser scope in lib/live_test_web/router.ex:
live "/places", PlaceLive.Index, :index
live "/places/new", PlaceLive.Index, :new
live "/places/:id/edit", PlaceLive.Index, :edit
live "/places/:id", PlaceLive.Show, :show
live "/places/:id/show/edit", PlaceLive.Show, :edit
Remember to update your repository by running migrations:
$ mix ecto.migrate
と上書きを聞かれますが、全て Y
でOKです。
そして、
同じマイグレーションファイルが出来るので、削除しておきましょう。
ルーティングの変更点
先程作成した
scope "/", LiveTestWeb do
pipe_through :browser
get "/", PlaceController, :index
resources "/places", PlaceController
end
こちらは、以下のように書き換えます。
scope "/", LiveTestWeb do
pipe_through :browser
live "/", PlaceLive.Index, :index
live "/places", PlaceLive.Index, :index
live "/places/new", PlaceLive.Index, :new
live "/places/:id/edit", PlaceLive.Index, :edit
live "/places/:id", PlaceLive.Show, :show
live "/places/:id/show/edit", PlaceLive.Show, :edit
end
これでLiveViewへの変換が終了です。
このタイミングで、以前のコントローラーや、テンプレートなども消してしまって良いです。
コントローラー側
LiveViewは感覚的にVue.jsのような感じでイベントやステートの管理が出来ます。
そしてすでに必要なコードが書かれていますので、変更点を書き加えて行く状態になります。
例えば、
こんな感じででボタンを動かしたい場合、 mount/3
に socket
にステートを入れてしまいます。
def mount(_params, _session, socket) do
socket = socket
|> assign(:data,
%{
columns:
%{
name: %{ status: "asc", icon: "▲" },
latitude: %{ status: "unsorted", icon: "■" },
longitude: %{ status: "unsorted", icon: "■" }
},
default_column: :name
}
)
{:ok, assign(socket, :places, list_places())}
end
そして、
def handle_event("sort", value, socket) do
{ :noreply, update(socket, :data, &(sort_data(&1, value))) }
end
このようなイベントハンドラーを書いておき、
def sort_data(data, value) do
sort = value["sort"]
|> String.to_atom
status = value["status"]
|> String.to_atom
result = fn
{ :desc, data } ->
_data = data
#ここで並び替え
dom_update(_data, sort, "asc", "▲")
{ _, data } ->
_data = data
#ここで並び替え
dom_update(_data, sort, "desc", "▼")
end
result.({ status, data })
end
def dom_update(data, sort, status, icon) do
data
|> put_in([:columns, data[:default_column]], %{ status: "unsorted", icon: "■" })
|> put_in([:columns, sort, :icon], icon )
|> put_in([:columns, sort, :status], status )
|> Map.put(:default_column, sort)
end
ステートに加えた変更を更新するといった流れです。
全体では、
defmodule LiveTestWeb.PlaceLive.Index do
use LiveTestWeb, :live_view
alias LiveTest.Prefectures
alias LiveTest.Prefectures.Place
@impl true
def mount(_params, _session, socket) do
socket = socket
|> assign(:data,
%{
columns:
%{
name: %{ status: "asc", icon: "▲" },
latitude: %{ status: "unsorted", icon: "■" },
longitude: %{ status: "unsorted", icon: "■" }
},
default_column: :name
}
)
{:ok, assign(socket, :places, list_places())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Place")
|> assign(:place, Prefectures.get_place!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Place")
|> assign(:place, %Place{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Places")
|> assign(:place, nil)
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
place = Prefectures.get_place!(id)
{:ok, _} = Prefectures.delete_place(place)
{:noreply, assign(socket, :places, list_places())}
end
defp list_places do
Prefectures.list_places()
end
def handle_event("sort", value, socket) do
{ :noreply, update(socket, :data, &(sort_data(&1, value))) }
end
def sort_data(data, value) do
sort = value["sort"]
|> String.to_atom
status = value["status"]
|> String.to_atom
result = fn
{ :desc, data } ->
_data = data
#ここで並び替え
dom_update(_data, sort, "asc", "▲")
{ _, data } ->
_data = data
#ここで並び替え
dom_update(_data, sort, "desc", "▼")
end
result.({ status, data })
end
def dom_update(data, sort, status, icon) do
data
|> put_in([:columns, data[:default_column]], %{ status: "unsorted", icon: "■" })
|> put_in([:columns, sort, :icon], icon )
|> put_in([:columns, sort, :status], status )
|> Map.put(:default_column, sort)
end
defp place_columns do
%{
name: %{ status: "asc", icon: "▲" },
latitude: %{ status: "unsorted", icon: "■" },
longitude: %{ status: "unsorted", icon: "■" }
}
end
end
このような感じになります。
テンプレート側
基本的にテンプレートも自動作成されているのですが、
<thead>
<tr>
<%= for column <- [:name, :latitude, :longitude] do %>
<th
phx-click="sort"
phx-value-sort={ column }
phx-value-status={ @data[:columns][column][:status] }
>
<%= Atom.to_string(column) %>:<%= @data[:columns][column][:icon] %>
</th>
<% end %>
<th></th>
</tr>
</thead>
このように、イベントのボタンをVueライクな感じで書き加えておきます。
全体では、
<h1>Listing Places</h1>
<%= if @live_action in [:new, :edit] do %>
<.modal return_to={Routes.place_index_path(@socket, :index)}>
<.live_component
module={LiveTestWeb.PlaceLive.FormComponent}
id={@place.id || :new}
title={@page_title}
action={@live_action}
place={@place}
return_to={Routes.place_index_path(@socket, :index)}
/>
</.modal>
<% end %>
<table>
<thead>
<tr>
<%= for column <- [:name, :latitude, :longitude] do %>
<th
phx-click="sort"
phx-value-sort={ column }
phx-value-status={ @data[:columns][column][:status] }
>
<%= Atom.to_string(column) %>:<%= @data[:columns][column][:icon] %>
</th>
<% end %>
<th></th>
</tr>
</thead>
<tbody id="places">
<%= for place <- @places do %>
<tr id={"place-#{place.id}"}>
<td><%= place.name %></td>
<td><%= place.latitude %></td>
<td><%= place.longitude %></td>
<td>
<span><%= live_redirect "Show", to: Routes.place_show_path(@socket, :show, place) %></span>
<span><%= live_patch "Edit", to: Routes.place_index_path(@socket, :edit, place) %></span>
<span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: place.id, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= live_patch "New Place", to: Routes.place_index_path(@socket, :new) %></span>
このような感じです。
データーの並び替え
あとはデーターを並び替えるだけです。
おわりに
ここまで見ていただきありがとうございます。
21日めは、