2018年9月17日

Phoenix_3_Ecto

Ecto 是 RDB 的 wrapper。他有個稱為 changesets 的功能,可保存所有對 database 的修改內容,他會封裝在寫入 RDB 前的 receiving external data, casting 及 validating 這些程序。

設定 Ecto

修改 /lib/rumbl/repo.ex

defmodule Rumbl.Repo do
    use Ecto.Repo, otp_app: :rumbl
end

修改 /lib/rumbl.ex,enable Ecto supervisor

defmodule Rumbl do
  use Application

  # See http://elixir-lang.org/docs/stable/elixir/Application.html
  # for more information on OTP Applications
  def start(_type, _args) do
    import Supervisor.Spec

    # Define workers and child supervisors to be supervised
    children = [
      # Start the Ecto repository
      supervisor(Rumbl.Repo, []),
      # Start the endpoint when the application starts
      supervisor(Rumbl.Endpoint, []),
      # Start your own worker by calling: Rumbl.Worker.start_link(arg1, arg2, arg3)
      # worker(Rumbl.Worker, [arg1, arg2, arg3]),
    ]

    # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Rumbl.Supervisor]
    Supervisor.start_link(children, opts)
  end

  # Tell Phoenix to update the endpoint configuration
  # whenever the application is updated.
  def config_change(changed, _new, removed) do
    Rumbl.Endpoint.config_change(changed, removed)
    :ok
  end
end

設定 DB /config/dev.exs

# Configure your database
config :rumbl, Rumbl.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "root",
  password: "password",
  database: "rumbl",
  hostname: "localhost",
  pool_size: 10

產生資料庫

$ mix ecto.create

DB Schema and Migration

修改 /web/models/user.ex 的內容

defmodule Rumbl.User do
    use Rumbl.Web, :model
    schema "users" do
        field :name, :string
        field :username, :string
        field :password, :string, virtual: true
        field :password_hash, :string
        timestamps
    end
end

DSL 是用 Elixir macro 實作的,schema 及 field 讓我們指定 DB table 及 Elixir struc,每個 field 會關聯到 DB table 的一個欄位,以及 User struct 的 field。

另外有增加一個 virtual 欄位,這是 password 的 hash 前結果,virtual field 不需要對應到 DB table 的欄位,因為 DB 只要紀錄 hash 後的密碼,Ecto 會自動定義 Elixir struct,所以可以直接使用 %Rumbl.User{}。

在 /web/web.ex 裡面 model 定義為

  def model do
    quote do
      use Ecto.Schema

      import Ecto
      import Ecto.Changeset
      # 增加  , only: [from: 1, from: 2]
      import Ecto.Query, only: [from: 1, from: 2]
    end
  end

現在我們需要用 "migrations" 的功能,將 DB schema 的定義反應到實際的 DB 裡面,migration 會修改資料庫以符合 application 的需要。

$ mix ecto.gen.migration create_user
* creating priv/repo/migrations
* creating priv/repo/migrations/20170903125816_create_user.exs

打開 priv/repo/migrations/20170903125816createuser.exs ,填入 change 的 function 內容(create table: users)

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

  def change do
    create table(:users) do
      add :name, :string
      add :username, :string, null: false
      add :password_hash, :string
      timestamps()
    end
    create unique_index(:users, [:username])
  end
end

Ecto.Migration API 提供了 create, remove, and change database tables, fields, and indexes 這些 functions。

$ mix ecto.migrate
[info] == Running Rumbl.Repo.Migrations.CreateUser.change/0 forward
[info] create table users
[info] create index users_username_index
[info] == Migrated in 0.0s

要注意,目前是針對 dev 環境的處理,如果要換別的環境,就要設定 MIX_ENV 這個環境變數。

利用 Repository 新增 data

利用 IEX 產生測試資料

$ iex -S mix
iex(1)> alias Rumbl.Repo
Rumbl.Repo
iex(2)> alias Rumbl.User
Rumbl.User


iex(3)> Repo.insert(%User{name: "Jose", username: "josie", password_hash: "<3<3elixir"})
[debug] QUERY OK db=2.7ms
INSERT INTO `users` (`name`,`password_hash`,`username`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["Jose", "<3<3elixir", "josie", {{2017, 9, 3}, {13, 9, 55, 939011}}, {{2017, 9, 3}, {13, 9, 55, 941294}}]
{:ok,
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
  inserted_at: ~N[2017-09-03 13:09:55.939011], name: "Jose", password: nil,
  password_hash: "<3<3elixir", updated_at: ~N[2017-09-03 13:09:55.941294],
  username: "josie"}}
  
  
iex(4)> Repo.insert(%User{name: "Bruce", username: "bruce", password_hash: "7langs"})
[debug] QUERY OK db=1.8ms
INSERT INTO `users` (`name`,`password_hash`,`username`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["Bruce", "7langs", "bruce", {{2017, 9, 3}, {13, 10, 3, 333839}}, {{2017, 9, 3}, {13, 10, 3, 333851}}]
{:ok,
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2,
  inserted_at: ~N[2017-09-03 13:10:03.333839], name: "Bruce", password: nil,
  password_hash: "7langs", updated_at: ~N[2017-09-03 13:10:03.333851],
  username: "bruce"}}
  
  
iex(5)> Repo.insert(%User{name: "Chris", username: "chris", password_hash: "phoenix"})
[debug] QUERY OK db=182.8ms
INSERT INTO `users` (`name`,`password_hash`,`username`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["Chris", "phoenix", "chris", {{2017, 9, 3}, {13, 10, 13, 487813}}, {{2017, 9, 3}, {13, 10, 13, 487825}}]
{:ok,
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 3,
  inserted_at: ~N[2017-09-03 13:10:13.487813], name: "Chris", password: nil,
  password_hash: "phoenix", updated_at: ~N[2017-09-03 13:10:13.487825],
  username: "chris"}}
  
  
iex(6)> Repo.all(User)
[debug] QUERY OK source="users" db=2.6ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 []
[%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
  inserted_at: ~N[2017-09-03 13:09:55.000000], name: "Jose", password: nil,
  password_hash: "<3<3elixir", updated_at: ~N[2017-09-03 13:09:55.000000],
  username: "josie"},
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2,
  inserted_at: ~N[2017-09-03 13:10:03.000000], name: "Bruce", password: nil,
  password_hash: "7langs", updated_at: ~N[2017-09-03 13:10:03.000000],
  username: "bruce"},
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 3,
  inserted_at: ~N[2017-09-03 13:10:13.000000], name: "Chris", password: nil,
  password_hash: "phoenix", updated_at: ~N[2017-09-03 13:10:13.000000],
  username: "chris"}]
  
  
iex(7)> Repo.get(User, 1)
[debug] QUERY OK source="users" db=1.9ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 WHERE (u0.`id` = ?) [1]
%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
 inserted_at: ~N[2017-09-03 13:09:55.000000], name: "Jose", password: nil,
 password_hash: "<3<3elixir", updated_at: ~N[2017-09-03 13:09:55.000000],
 username: "josie"}

從 mix phoenix.server 的 console log 也能發現,查詢的語法已經改用 SQL 連接 DB,而不是 memory DB。

$ mix phoenix.server

[info] GET /users
[debug] Processing by Rumbl.UserController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[debug] QUERY OK source="users" db=2.2ms decode=4.6ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 []
[info] Sent 200 in 82ms

Building Forms

Phoenix form builder

修改 /web/controllers/user_controller.ex,增加 new 新增 User 的 function

defmodule Rumbl.UserController do
  use Rumbl.Web, :controller

  # 取得所有 users
  def index(conn, _params) do
    users = Repo.all(Rumbl.User)
    render conn, "index.html", users: users
  end

  # 顯示某個 id 的 user
  def show(conn, %{"id" => id}) do
    user = Repo.get(Rumbl.User, id)
    render conn, "show.html", user: user
  end

  alias Rumbl.User
  # 利用 changeset 功能新增 user
  def new(conn, _params) do
    changeset = User.changeset(%User{})
    render conn, "new.html", changeset: changeset
  end
end

修改 /web/models/user.ex

defmodule Rumbl.User do
  use Rumbl.Web, :model
  schema "users" do
    field :name, :string
    field :username, :string
    field :password, :string, virtual: true
    field :password_hash, :string
    timestamps()
  end

  # 參數: a User struct and parameters
  # User struct 傳入 cast,限制 name 及 username 都是必要欄位
  # 並將 requied & optional values 轉成 schema types
  # 接下來傳入 validate_length,檢查 username 資料長度

  # 如果沒有任何 parameters,必須傳入 empty map  %{}
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, ~w(name username), [])
    |> validate_length(:username, min: 1, max: 20)
  end
end

注意 User.changeset 的部分,他會接收一個 struct 及 controller 的參數,回傳 Ecto.Changeset。

Changeset 可讓 Ecto 管理 record changes, cast parameters, perform validations。可使用 changeset 製作 customized strategy 處理各種資料修改的功能,例如新增使用者或更新資訊。

注意: 在舊版 Ecto 要用 :empty 當作 empty map,但在 Ecto 2.0 後,要改寫為 %{}

ref: Empty atom in Ecto changeset

接下來修改 /web/router.ex 的 scope

  scope "/", Rumbl do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    resources "/users", UserController, only: [:index, :show, :new, :create]
  end

resources 是一個簡化的一組 REST action implementations,包含 create, read, update, delete

resources "/users", UserController

等同以下這些網址的定義

get "/users", UserController, :index
get "/users/new", UserController, :new
get "/users/:id", UserController, :show

get "/users/:id/edit", UserController, :edit

post "/users", UserController, :create
patch "/users/:id", UserController, :update
put "/users/:id", UserController, :update
delete "/users/:id", UserController, :delete

剛剛的定義用 only: 限制只使用某幾個 route 定義

resources "/users", UserController, only: [:index, :show, :new, :create]

可在 console 直接用這個指令查看整個 application 的 routes

$ mix phoenix.routes
page_path  GET   /           Rumbl.PageController :index
user_path  GET   /users      Rumbl.UserController :index
user_path  GET   /users/new  Rumbl.UserController :new
user_path  GET   /users/:id  Rumbl.UserController :show
user_path  POST  /users      Rumbl.UserController :create

新增網頁

<h1>New User</h1>

<%= form_for @changeset, user_path(@conn, :create), fn f -> %>
  <div class="form-group">
    <%= text_input f, :name, placeholder: "Name", class: "form-control" %>
  </div>
  <div class="form-group">
    <%= text_input f, :username, placeholder: "Username", class: "form-control" %>
  </div>
  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password", class: "form-control" %>
  </div>
  <%= submit "Create User", class: "btn btn-primary" %>
<% end %>

http://localhost:4000/users/new

<h1>New User</h1>

<form accept-charset="UTF-8" action="/users" method="post"><input name="_csrf_token" type="hidden" value="VQAbE0ACaEdYJglWOgIbMioeICosAAAA60MX/k8+nSByckvzFyogeg=="><input name="_utf8" type="hidden" value="✓"><div class="form-group">
<input class="form-control" id="user_name" name="user[name]" placeholder="Name" type="text"></div>
<div class="form-group">
<input class="form-control" id="user_username" name="user[username]" placeholder="Username" type="text"></div>
<div class="form-group">
<input class="form-control" id="user_password" name="user[password]" placeholder="Password" type="password"></div>
<button class="btn btn-primary" type="submit">Create User</button></form>

另外有個 protocol: Phoenix.HTML.FormData,可分離畫面以及後端實作,Ecto.Changeset 也實作了這個 Protocol,並把 Table 的 data strcuture 直接轉換到 Phoenix form。

Creating Resources

  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)
    # Repo.insert 的結果有兩種,正確時,會 redirect 到 users index 首頁
    # 錯誤時,會停留在 new.html 的畫面
    case Repo.insert(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "#{user.name} created!")
        |> redirect(to: user_path(conn, :index))
      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

修改 /web/templates/user/new.html.eex ,加上 error message

<h1>New User</h1>

<%= form_for @changeset, user_path(@conn, :create), fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <div class="form-group">
    <%= text_input f, :name, placeholder: "Name", class: "form-control" %>
    <%= error_tag f, :name %>
  </div>
  <div class="form-group">
    <%= text_input f, :username, placeholder: "Username", class: "form-control" %>
    <%= error_tag f, :username %>
  </div>
  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password", class: "form-control" %>
    <%= error_tag f, :password %>
  </div>
  <%= submit "Create User", class: "btn btn-primary" %>
<% end %>

error_tag helper 定義在 web/views/error_helpers.ex 可顯示錯誤訊息。


$ iex -S mix

iex(1)> changeset = Rumbl.User.changeset(%Rumbl.User{username: "eric"})
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.User<>,
 valid?: true>

iex(2)> import Ecto.Changeset
Ecto.Changeset

iex(3)> changeset = put_change(changeset, :username, "eric")
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.User<>,
 valid?: true>
iex(4)> changeset.changes
%{}

iex(5)> changeset = put_change(changeset, :username, "ericname")
#Ecto.Changeset<action: nil, changes: %{username: "ericname"}, errors: [],
 data: #Rumbl.User<>, valid?: true>
iex(6)> changeset.changes
%{username: "ericname"}
iex(7)>  get_change(changeset, :username)
"ericname"

Ecto 使用 changesets 儲存 persistence 前後異動的資料。

References

Programming Phoenix