2018/10/8

Phoenix_5_DeeperEcto


更深入了解 Ecto


Generators


瞭解如何定義 table 的關聯,User 會選擇 video,video裡有多個 comments(annotations),comments 是由 User 建立的。



可使用 generators 產生 skeleton: 包含 migration, controllers, templates


video 相關的欄位為


  1. an associated User
  2. A creation time for the video
  3. A URL of the video location
  4. A title
  5. type of the video

使用 phoenix.gen.html Mix task


mix phoenix.gen.html Video videos user_id:references:users url:string title:string description:text

$ mix phoenix.gen.html Video videos user_id:references:users url:string title:string description:text
* creating web/controllers/video_controller.ex
* creating web/templates/video/edit.html.eex
* creating web/templates/video/form.html.eex
* creating web/templates/video/index.html.eex
* creating web/templates/video/new.html.eex
* creating web/templates/video/show.html.eex
* creating web/views/video_view.ex
* creating test/controllers/video_controller_test.exs
* creating web/models/video.ex
* creating test/models/video_test.exs
* creating priv/repo/migrations/20170905083531_create_video.exs

Add the resource to your browser scope in web/router.ex:

    resources "/videos", VideoController

Remember to update your repository by running migrations:

    $ mix ecto.migrate

執行以下這個指令,DB 就會產生 table: videos


mix ecto.migrate

執行後會得到


  1. 定義 model 的 module name
  2. model name
  3. 每個欄位及 type information



如果要限制 /videos 只能讓已登入的使用者使用,前面已經寫過 authentication 的 functions


/web/controller/user_controller.ex


  defp authenticate(conn, _opts) do
    if conn.assigns.current_user do
      conn
    else
      conn
      |> put_flash(:error, "You must be logged in to access that page")
      |> redirect(to: page_path(conn, :index))
      |> halt()
    end
  end

把這個部分的 code 移到 /web/controllers/auth.ex


  import Phoenix.Controller
  alias Rumbl.Router.Helpers

  def authenticate_user(conn, _opts) do
    if conn.assigns.current_user do
      conn
    else
      conn
      |> put_flash(:error, "You must be logged in to access that page")
      |> redirect(to: Helpers.page_path(conn, :index))
      |> halt()
    end
  end

修改 /web/web.ex ,增加 import Rumbl.Auth, only: [authenticate_user: 2],把 authenticate_user 提供給 controller 及 router 使用


  def controller do
    quote do
      use Phoenix.Controller

      alias Rumbl.Repo
      import Ecto
      import Ecto.Query

      import Rumbl.Router.Helpers
      import Rumbl.Gettext

      import Rumbl.Auth, only: [authenticate_user: 2] # New import
    end
  end
  
  def router do
    quote do
      use Phoenix.Router

      import Rumbl.Auth, only: [authenticate_user: 2] # New import
    end
  end

修改 /web/controllers/usercontroller.ex ,將 :authenticate plug 改成 :authenticateuser


plug :authenticate_user when action in [:index, :show]

回到 router,定義新 scope /manage 包含 /videos resources


  scope "/manage", Rumbl do
    pipe_through [:browser, :authenticate_user]

    resources "/videos", VideoController
  end


測試一下 new, update, delete, read video 資料,全部都有了




VideoController 也有 pipeline,可使用 scrub_params


plug :scrub_params, "video" when action in [:create, :update]

因為 HTML form 沒有 nil 的概念,所以 blank input 都會被轉成 empty string,scrub_params 可將 form 參數裡面的 empty string 轉換為 nil




產生 DB Migrations


打開 /priv/repo/migrations/20170905083531_create_video.exs,增加 down function 處理 DB rollback


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

  def change do
    create table(:videos) do
      add :url, :string
      add :title, :string
      add :description, :text
      add :user_id, references(:users, on_delete: :nothing)

      timestamps()
    end
    create index(:videos, [:user_id])

  end

  def down do
#    drop constraint(:videos, "videos_user_id_fkey")
    execute "ALTER TABLE videos DROP FOREIGN KEY videos_user_id_fkey"
    alter table(:videos) do
      modify :user_id, references(:users, on_delete: :nothing)
    end

    drop_if_exists table("videos")
  end
end

可將 ecto videos table rollback 回去


$ mix ecto.rollback
[info] == Running Rumbl.Repo.Migrations.CreateVideo.down/0 forward
[info] execute "ALTER TABLE videos DROP FOREIGN KEY videos_user_id_fkey"
[info] alter table videos
[info] drop table if exists videos
[info] == Migrated in 0.0s


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

Building Relationships


/web/models/video.ex


defmodule Rumbl.Video do
  use Rumbl.Web, :model

  schema "videos" do
    field :url, :string
    field :title, :string
    field :description, :string

    # 定義 :user_id 欄位, 為 User.id 的 foreign key
    belongs_to :user, Rumbl.User

    timestamps()
  end

  @required_fields ~w(url title description)
  @optional_fields ~w()

  def changeset(model, params \\ %{}) do
    model
    |> cast(params, @required_fields, @optional_fields)
  end
end

/web/models/user.ex 加上 has_many :videos, Rumbl.Video


  use Rumbl.Web, :model
  schema "users" do
    field :name, :string
    field :username, :string
    field :password, :string, virtual: true
    field :password_hash, :string
    has_many :videos, Rumbl.Video

    timestamps()
  end

測試


$  iex -S mix

iex(1)> alias Rumbl.Repo
Rumbl.Repo
iex(2)> alias Rumbl.User
Rumbl.User
iex(3)> import Ecto.Query
Ecto.Query

iex(4)> user = Repo.get_by!(User, username: "josie")
[debug] QUERY OK source="users" db=4.2ms decode=2.6ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 WHERE (u0.`username` = ?) ["josie"]
%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: "$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: #Ecto.Association.NotLoaded<association :videos is not loaded>}
 
iex(5)> user.videos
#Ecto.Association.NotLoaded<association :videos is not loaded>


# Repo.preload 接受 one / collection of names,取得所有相關的資料。
iex(6)> user = Repo.preload(user, :videos)
[debug] QUERY OK source="videos" db=1.6ms
SELECT v0.`id`, v0.`url`, v0.`title`, v0.`description`, v0.`user_id`, v0.`inserted_at`, v0.`updated_at`, v0.`user_id` FROM `videos` AS v0 WHERE (v0.`user_id` = ?) ORDER BY v0.`user_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: "$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie", videos: []}

iex(7)> user.videos
[]

iex(14)> user = Repo.get_by!(User, username: "josie")
[debug] QUERY OK source="users" db=0.8ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 WHERE (u0.`username` = ?) ["josie"]
%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: "$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: #Ecto.Association.NotLoaded<association :videos is not loaded>}
 
 
iex(15)> attrs = %{title: "hi", description: "says hi", url: "example.com"}
%{description: "says hi", title: "hi", url: "example.com"}


iex(16)>  video = Ecto.build_assoc(user, :videos, attrs)
%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:built, "videos">,
 description: "says hi", id: nil, inserted_at: nil, title: "hi",
 updated_at: nil, url: "example.com",
 user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}
 
 
iex(17)> video = Repo.insert!(video)
[debug] QUERY OK db=0.2ms
begin []
[debug] QUERY OK db=6.2ms
INSERT INTO `videos` (`description`,`title`,`url`,`user_id`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?,?) ["says hi", "hi", "example.com", 1, {{2017, 9, 5}, {13, 37, 58, 291187}}, {{2017, 9, 5}, {13, 37, 58, 291202}}]
[debug] QUERY OK db=1.0ms
commit []
%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
 description: "says hi", id: 1, inserted_at: ~N[2017-09-05 13:37:58.291187],
 title: "hi", updated_at: ~N[2017-09-05 13:37:58.291202], url: "example.com",
 user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}

Ecto.buildassoc 可產生 struct,並有所有有關聯的欄位,呼叫 buildassoc 等同於呼叫


%Rumbl.Video{user_id: user.id, title: "hi", description: "says hi", url: "example.com"}

再重新測試一次


iex(1)> alias Rumbl.Repo
Rumbl.Repo
iex(2)> alias Rumbl.User
Rumbl.User
iex(3)> import Ecto.Query
Ecto.Query

iex(4)> user = Repo.get_by!(User, username: "josie")
[debug] QUERY OK source="users" db=2.2ms decode=2.5ms queue=0.1ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 WHERE (u0.`username` = ?) ["josie"]
%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: "$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: #Ecto.Association.NotLoaded<association :videos is not loaded>}
 
 
iex(5)> user = Repo.preload(user, :videos)
[debug] QUERY OK source="videos" db=0.7ms
SELECT v0.`id`, v0.`url`, v0.`title`, v0.`description`, v0.`user_id`, v0.`inserted_at`, v0.`updated_at`, v0.`user_id` FROM `videos` AS v0 WHERE (v0.`user_id` = ?) ORDER BY v0.`user_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: "$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: [%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
   description: "says hi", id: 1, inserted_at: ~N[2017-09-05 13:37:58.000000],
   title: "hi", updated_at: ~N[2017-09-05 13:37:58.000000], url: "example.com",
   user: #Ecto.Association.NotLoaded<association :user is not loaded>,
   user_id: 1}]}

iex(6)> user.videos
[%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
  description: "says hi", id: 1, inserted_at: ~N[2017-09-05 13:37:58.000000],
  title: "hi", updated_at: ~N[2017-09-05 13:37:58.000000], url: "example.com",
  user: #Ecto.Association.NotLoaded<association :user is not loaded>,
  user_id: 1}]
  
  
  
iex(7)> query = Ecto.assoc(user, :videos)
#Ecto.Query<from v in Rumbl.Video, where: v.user_id == ^1>

iex(8)> Repo.all(query)
[debug] QUERY OK source="videos" db=0.7ms
SELECT v0.`id`, v0.`url`, v0.`title`, v0.`description`, v0.`user_id`, v0.`inserted_at`, v0.`updated_at` FROM `videos` AS v0 WHERE (v0.`user_id` = ?) [1]
[%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
  description: "says hi", id: 1, inserted_at: ~N[2017-09-05 13:37:58.000000],
  title: "hi", updated_at: ~N[2017-09-05 13:37:58.000000], url: "example.com",
  user: #Ecto.Association.NotLoaded<association :user is not loaded>,
  user_id: 1}]

Managing Related Data


video controller 可對 videos 進行 CRUD,但我們要將 videos 跟 users 連結在一起


可修改 /web/controllers/video_controller.ex


  # 跟現在登入的 user 連結在一起,用該 user create video
  def new(conn, _params) do
    changeset =
      conn.assigns.current_user
      |> build_assoc(:videos)
      |> Video.changeset()

    render(conn, "new.html", changeset: changeset)
  end

更進一步用另一種寫法,在 /web/controllers/video_controller.ex 加上 action(conn, _) ,同時修改 new, create 加上 user 參數


  def action(conn, _) do
    apply(__MODULE__, action_name(conn),
      [conn, conn.params, conn.assigns.current_user])
  end
  
  def new(conn, _params, user) do
    changeset =
      user
      |> build_assoc(:videos)
      |> Video.changeset()

    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"video" => video_params}, user) do
    changeset =
      user
      |> build_assoc(:videos)
      |> Video.changeset(video_params)

    case Repo.insert(changeset) do
      {:ok, _video} ->
        conn
        |> put_flash(:info, "Video created successfully.")
        |> redirect(to: video_path(conn, :index))
      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

另外再增加 user_videos,取得某個 user 所有 videos


  defp user_videos(user) do
    assoc(user, :videos)
  end

再修改 index, show


  def index(conn, _params, user) do
    videos = Repo.all(user_videos(user))
    render(conn, "index.html", videos: videos)
  end
  
  def show(conn, %{"id" => id}, user) do
    video = Repo.get!(user_videos(user), id)
    render(conn, "show.html", video: video)
  end

update, edit, delete 也加上 user 參數


def edit(conn, %{"id" => id}, user) do
    video = Repo.get!(user_videos(user), id)
    changeset = Video.changeset(video)
    render(conn, "edit.html", video: video, changeset: changeset)
  end

  def update(conn, %{"id" => id, "video" => video_params}, user) do
    video = Repo.get!(user_videos(user), id)
    changeset = Video.changeset(video, video_params)

    case Repo.update(changeset) do
      {:ok, video} ->
        conn
        |> put_flash(:info, "Video updated successfully.")
        |> redirect(to: video_path(conn, :show, video))
      {:error, changeset} ->
        render(conn, "edit.html", video: video, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}, user) do
    video = Repo.get!(user_videos(user), id)
    Repo.delete!(video)

    conn
    |> put_flash(:info, "Video deleted successfully.")
    |> redirect(to: video_path(conn, :index))
  end



Adding Categories


$ mix phoenix.gen.model Category categories name:string* creating web/models/category.ex
* creating test/models/category_test.exs
* creating priv/repo/migrations/20170905140814_create_category.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

修改 migration,加上 null: false,以及 index


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

  def change do
    create table(:categories) do
      add :name, :string, null: false

      timestamps()
    end

    create unique_index(:categories, [:name])

  end
end

修改 /web/models/video.ex belongs_to :category, Rumbl.Category


  schema "videos" do
    field :url, :string
    field :title, :string
    field :description, :string

    # 定義 :user_id 欄位, 為 User.id 的 foreign key
    belongs_to :user, Rumbl.User
    belongs_to :category, Rumbl.Category

    timestamps()
  end

adds category_id to video


$ mix ecto.gen.migration add_category_id_to_video
Compiling 14 files (.ex)
warning: function authenticate/2 is unused
  web/controllers/user_controller.ex:40

Generated rumbl app
* creating priv/repo/migrations
* creating priv/repo/migrations/20170905141843_add_category_id_to_video.exs

修改 priv/repo/migrations/20170905141843addcategoryidto_video.exs


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

  def change do
     ## enforce a constraint between
videos and categories
    alter table(:videos) do
      add :category_id, references(:categories)
    end
  end
end

migrate database


$ mix ecto.migrate
[info] == Running Rumbl.Repo.Migrations.CreateCategory.change/0 forward
[info] create table categories
[info] create index categories_name_index
[info] == Migrated in 0.0s
[info] == Running Rumbl.Repo.Migrations.AddCategoryIdToVideo.change/0 forward
[info] alter table videos
[info] == Migrated in 0.0s



Setup Category Seed Data


如果要讓 categories 固定,但不想產生 contoller, view, templates,只需要在啟動時,在 database 塞入資料。


Phoenix 已經有個 seeding data 的功能,在 /priv/repo/seeds.exs,填寫以下 code


alias Rumbl.Repo
alias Rumbl.Category

for category <- ~w(Action Drama Romance Comedy Sci-fi) do
  Repo.insert!(%Category{name: category})
end

$ mix run priv/repo/seeds.exs
[debug] QUERY OK db=5.1ms
INSERT INTO `categories` (`name`,`inserted_at`,`updated_at`) VALUES (?,?,?) ["Action", {{2017, 9, 5}, {14, 24, 45, 713209}}, {{2017, 9, 5}, {14, 24, 45, 715805}}]
[debug] QUERY OK db=2.2ms queue=0.1ms
INSERT INTO `categories` (`name`,`inserted_at`,`updated_at`) VALUES (?,?,?) ["Drama", {{2017, 9, 5}, {14, 24, 45, 749923}}, {{2017, 9, 5}, {14, 24, 45, 749940}}]
[debug] QUERY OK db=1.8ms queue=0.1ms
INSERT INTO `categories` (`name`,`inserted_at`,`updated_at`) VALUES (?,?,?) ["Romance", {{2017, 9, 5}, {14, 24, 45, 752539}}, {{2017, 9, 5}, {14, 24, 45, 752546}}]
[debug] QUERY OK db=0.6ms queue=0.1ms
INSERT INTO `categories` (`name`,`inserted_at`,`updated_at`) VALUES (?,?,?) ["Comedy", {{2017, 9, 5}, {14, 24, 45, 754714}}, {{2017, 9, 5}, {14, 24, 45, 754719}}]
[debug] QUERY OK db=0.7ms
INSERT INTO `categories` (`name`,`inserted_at`,`updated_at`) VALUES (?,?,?) ["Sci-fi", {{2017, 9, 5}, {14, 24, 45, 755721}}, {{2017, 9, 5}, {14, 24, 45, 755730}}]

如果執行兩次會發生 error,因為 DB 有設定 unique index 的關係


$ mix run priv/repo/seeds.exs
[debug] QUERY ERROR db=6.1ms
INSERT INTO `categories` (`name`,`inserted_at`,`updated_at`) VALUES (?,?,?) ["Action", {{2017, 9, 5}, {14, 25, 5, 952360}}, {{2017, 9, 5}, {14, 25, 5, 954959}}]
** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * unique: categories_name_index

再修改 seeds.exs,這樣就不會出現 error


alias Rumbl.Repo
alias Rumbl.Category

for category <- ~w(Action Drama Romance Comedy Sci-fi) do
  Repo.get_by(Category, name: category) ||
    Repo.insert!(%Category{name: category})
end



Associating Videos and Categories


  1. 由 DB 取得所有 categories names and IDs
  2. 以 name 排序
  3. 傳入 view 並放在 select input 裡面

首先測試 Query 功能


$ iex -S mix
Erlang/OTP 20 [erts-9.0] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false]

Interactive Elixir (1.5.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> import Ecto.Query
Ecto.Query
iex(2)> alias Rumbl.
Auth                 Category             Endpoint
ErrorHelpers         ErrorView            Gettext
LayoutView           Mixfile              PageController
PageView             Repo                 Router
SessionController    SessionView          User
UserController       UserSocket           UserView
Video                VideoController      VideoView
Web                  config_change/3      start/2

iex(2)> alias Rumbl.Repo
Rumbl.Repo
iex(3)> alias Rumbl.Category
Rumbl.Category

iex(4)> Repo.all from c in Category, select: c.name
[debug] QUERY OK source="categories" db=2.3ms
SELECT c0.`name` FROM `categories` AS c0 []
["Action", "Comedy", "Drama", "Romance", "Sci-fi"]

  • Repo.all 回傳所有 rows
  • from 是產生 query 的 macro
  • c in Category 表示取得 category 所有 rows
  • select: c.name 表示只需要 name 欄位
  • order_by 是排序

iex(6)> Repo.all from c in Category, order_by: c.name, select: {c.name, c.id}
[debug] QUERY OK source="categories" db=0.6ms
SELECT c0.`name`, c0.`id` FROM `categories` AS c0 ORDER BY c0.`name` []
[{"Action", 1}, {"Comedy", 4}, {"Drama", 2}, {"Romance", 3}, {"Sci-fi", 5}]

Ecto 的 query 是 composable


Ecto 定義了 Ecto.Queryable queryable protocol, from 會接受 queryable,可使用任何一個 queryable 當作新的 queryable 的基礎


iex(8)> query = Category
Rumbl.Category
iex(9)> query = from c in query, order_by: c.name
#Ecto.Query<from c in Rumbl.Category, order_by: [asc: c.name]>
iex(10)> query = from c in query, select: {c.name, c.id}
#Ecto.Query<from c in Rumbl.Category, order_by: [asc: c.name],
 select: {c.name, c.id}>
iex(11)> Repo.all query
[debug] QUERY OK source="categories" db=0.6ms
SELECT c0.`name`, c0.`id` FROM `categories` AS c0 ORDER BY c0.`name` []
[{"Action", 1}, {"Comedy", 4}, {"Drama", 2}, {"Romance", 3}, {"Sci-fi", 5}]

在 /web/models/category.ex 新增兩個 function


  # 以 queryable 為參數,且 return queryable
  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

/web/controllers/video_controller.ex 新增


  alias Rumbl.Category

  plug :load_categories when action in [:new, :create, :edit, :update]

  defp load_categories(conn, _) do
    query =
      Category
      |> Category.alphabetical
      |> Category.names_and_ids
    categories = Repo.all query
    assign(conn, :categories, categories)
  end

/web/templates/video/form.html.eex 新增


<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>


修改 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) %>

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) %>

Deeper into Ecto Queries


$ iex -S mix
iex(1)> import Ecto.Query
Ecto.Query
iex(2)> alias Rumbl.Repo
Rumbl.Repo
iex(3)> alias Rumbl.User
Rumbl.User

iex(4)> username = "josie"
"josie"

iex(5)> Repo.one(from u in User, where: u.username == ^username)
[debug] QUERY OK source="users" db=2.2ms decode=2.7ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 WHERE (u0.`username` = ?) ["josie"]
%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: "$2b$12$DtmZgYW2J.jxy3I0kNDAI.ooOsdcOXc6bezZuBzaTwaYSxbF1oBIS",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: #Ecto.Association.NotLoaded<association :videos is not loaded>}

  • Repo.one 只要回傳 one row,並不表示只有一個結果,而是只需要一個結果
  • from u in User 表示要讀取 User schema
  • where: u.username == ^username ,使用 ^ 可保證 username 永遠不會異動,^ 也可保證不會發生 sql injection
  • select 可省略,表示回傳整個 struct
  • 型別錯誤會發生 error

iex(7)> username = 123
123
iex(8)> Repo.one(from u in User, where: u.username == ^username)
** (Ecto.Query.CastError) iex:8: value `123` in `where` cannot be cast to type :string in query:

from u in Rumbl.User,
  where: u.username == ^123,
  select: u



Phoenix 的 MVC 如下



  1. controller 由 socket 讀取資料
  2. parse 資料 into data structures (ex: params map)
  3. 傳送到 Model,轉換參數為 changesets/queries
  4. Elixir structs, changesets, queries 都只是資料,可以在 function 中一個傳給一個,一步一步修改資料
  5. 透過 Repo 修改到 DB/發送 email 給系統負責人
  6. 最後呼叫 view,將 model 轉成 view data (JSON/HTML)



Query API


可在 query 中使用的


  1. Comparison operators: ==, !=, <=, >=, <, >
  2. Boolean operators: and, or, not
  3. Inclusion operator: in
  4. Search functions: like and ilike
  5. Null check functions: is_nil
  6. Aggregates: count, avg, sum, min, max
  7. Date/time intervals: datetimeadd, dateadd
  8. General: fragment, field, and type

可使用 keyword syntax 或是 pipe syntax




以 Keyword syntax 撰寫 Queries


Repo.one from u in User,
    select: count(u.id),
    where: like(u.username, ^"j%")
        or like(u.username, ^"c%")

iex(9)> Repo.one from u in User, select: count(u.id), where: like(u.username, ^"j%") or like(u.username, ^"c%")
[debug] QUERY OK source="users" db=6.2ms
SELECT count(u0.`id`) FROM `users` AS u0 WHERE ((u0.`username` LIKE ?) OR (u0.`username` LIKE ?)) ["j%", "c%"]
2

iex(12)> users_count = from u in User, select: count(u.id)
#Ecto.Query<from u in Rumbl.User, select: count(u.id)>

iex(13)> j_users = from u in users_count, where: like(u.username, ^"%j%")
#Ecto.Query<from u in Rumbl.User, where: like(u.username, ^"%j%"),
 select: count(u.id)>

iex(14)> j_users = from q in users_count, where: like(q.username, ^"%j%")
#Ecto.Query<from u in Rumbl.User, where: like(u.username, ^"%j%"),
 select: count(u.id)>



以 Pipe syntax 撰寫 Queries


User |>
select([u], count(u.id)) |>
where([u], like(u.username, ^"j%") or like(u.username, ^"c%")) |>
Repo.one()


iex(17)> User |>
...(17)> select([u], count(u.id)) |>
...(17)> where([u], like(u.username, ^"j%") or like(u.username, ^"c%")) |>
...(17)> Repo.one()
[debug] QUERY OK source="users" db=0.8ms
SELECT count(u0.`id`) FROM `users` AS u0 WHERE ((u0.`username` LIKE ?) OR (u0.`username` LIKE ?)) ["j%", "c%"]
2

Fragments


Ecto query fragment 會發送 DB 部分的 query,並能以更安全的方式產生 query string


from(u in User,
    where: fragment("lower(username) = ?",
    ^String.downcase(uname)))

Ecto 也可以直接執行 SQL


Ecto.Adapters.SQL.query(Rumbl.Repo, "SELECT power(?, ?)", [2, 10])

# note: PostgreSQL 要改為
Ecto.Adapters.SQL.query(Rumbl.Repo, "SELECT power($1, $2)", [2, 10])

iex(24)> Ecto.Adapters.SQL.query(Rumbl.Repo, "SELECT power(?, ?)", [2, 10])
[debug] QUERY OK db=6.4ms
SELECT power(?, ?) [2, 10]
{:ok,
 %Mariaex.Result{columns: ["power(?, ?)"], connection_id: nil,
  last_insert_id: nil, num_rows: 1, rows: [[1024.0]]}}

Querying Relationships


Ecto 支援 association relationship,可用 Repo.preload 取得 associated data


iex(1)> import Ecto.Query
Ecto.Query
iex(2)> alias Rumbl.Repo
Rumbl.Repo
iex(3)> alias Rumbl.User
Rumbl.User

# 取得一個 user
iex(4)> user = Repo.one from(u in User, limit: 1)
[debug] QUERY OK source="users" db=3.4ms decode=5.8ms
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-09-03 13:09:55.000000], name: "Jose", password: nil,
 password_hash: "$2b$12$DtmZgYW2J.jxy3I0kNDAI.ooOsdcOXc6bezZuBzaTwaYSxbF1oBIS",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: #Ecto.Association.NotLoaded<association :videos is not loaded>}
 
# user.videos 尚未 preload
iex(5)> user.videos
#Ecto.Association.NotLoaded<association :videos is not loaded>

# preload :videos
iex(6)> user = Repo.preload(user, :videos)
[debug] QUERY OK source="videos" db=0.8ms
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` = ?) ORDER BY v0.`user_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: "$2b$12$DtmZgYW2J.jxy3I0kNDAI.ooOsdcOXc6bezZuBzaTwaYSxbF1oBIS",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: [%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
   category: #Ecto.Association.NotLoaded<association :category is not loaded>,
   category_id: nil, description: "test", id: 2,
   inserted_at: ~N[2017-09-05 14:06:33.000000], title: "test",
   updated_at: ~N[2017-09-05 14:06:33.000000], url: "test",
   user: #Ecto.Association.NotLoaded<association :user is not loaded>,
   user_id: 1},
  %Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
   category: #Ecto.Association.NotLoaded<association :category is not loaded>,
   category_id: nil, description: "t2", id: 3,
   inserted_at: ~N[2017-09-05 16:11:55.000000], title: "t2",
   updated_at: ~N[2017-09-05 16:11:55.000000], url: "t2",
   user: #Ecto.Association.NotLoaded<association :user is not loaded>,
   user_id: 1}]}

iex(7)> user.videos

# 也可以直接在 query 時 preload
iex(8)> user = Repo.one from(u in User, limit: 1, preload: [:videos])
[debug] QUERY OK source="users" db=0.9ms
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=0.8ms
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` = ?) ORDER BY v0.`user_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: "$2b$12$DtmZgYW2J.jxy3I0kNDAI.ooOsdcOXc6bezZuBzaTwaYSxbF1oBIS",
 updated_at: ~N[2017-09-05 01:22:52.000000], username: "josie",
 videos: [%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
   category: #Ecto.Association.NotLoaded<association :category is not loaded>,
   category_id: nil, description: "test", id: 2,
   inserted_at: ~N[2017-09-05 14:06:33.000000], title: "test",
   updated_at: ~N[2017-09-05 14:06:33.000000], url: "test",
   user: #Ecto.Association.NotLoaded<association :user is not loaded>,
   user_id: 1},
  %Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
   category: #Ecto.Association.NotLoaded<association :category is not loaded>,
   category_id: nil, description: "t2", id: 3,
   inserted_at: ~N[2017-09-05 16:11:55.000000], title: "t2",
   updated_at: ~N[2017-09-05 16:11:55.000000], url: "t2",
   user: #Ecto.Association.NotLoaded<association :user is not loaded>,
   user_id: 1}]}

加上過濾條件


Repo.all from u in User,
join: v in assoc(u, :videos),
join: c in assoc(v, :category),
where: c.name == "Comedy",
select: {u, v}

iex(9)> Repo.all from u in User,
...(9)> join: v in assoc(u, :videos),
...(9)> join: c in assoc(v, :category),
...(9)> where: c.name == "Comedy",
...(9)> select: {u, v}
[debug] QUERY OK source="users" db=5.4ms
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') []
[]

Constraints


  • constraint


    explicit database constraint 可能是 unique constraint on an index,或是 integrity constant between primary and foreign keys

  • constraint error


    Ecto.ConstraintError (如果 add 一樣的 category 兩次)

  • changeset constraint


    附加到 changeset 的 a constraint annotation,可讓 Ecto 轉換 constrant error 為 changeset error messages

  • changeset error message




Validating Unique Data (Table Primary Key)


在 DB migration 中加上 unique index


create unique_index(:users, [:username])

/web/models/user.ex,changeset 加上 unique_constraint


  def changeset(model, params \\ %{}) do
    model
    |> cast(params, ~w(name username), [])
    |> validate_length(:username, min: 1, max: 20)
    |> unique_constraint(:username)
  end



Validating Foreign Keys


在 /web/models/video.ex 加上 category constraint


notes: 書本是寫成 @optionalfields ~w(categoryid),但是把 categoryid 放在 requiredfields,changeset 資料才會處理 category_id 欄位,這裡是比較奇怪的地方...


  @required_fields ~w(url title description category_id)
  @optional_fields ~w()

  def changeset(model, params \\ %{}) do
    Logger.debug "params: #{inspect(params)}"
    model
    |> cast(params, @required_fields, @optional_fields)
    |> assoc_constraint(:category)
  end

$ iex -S mix

iex(1)> alias Rumbl.Category
Rumbl.Category
iex(2)> alias Rumbl.Video
Rumbl.Video
iex(3)> alias Rumbl.Repo
Rumbl.Repo
iex(4)> import Ecto.Query
Ecto.Query

# 取得 Drama category
iex(5)> category = Repo.get_by Category, name: "Drama"
[debug] QUERY OK source="categories" db=2.9ms
SELECT c0.`id`, c0.`name`, c0.`inserted_at`, c0.`updated_at` FROM `categories` AS c0 WHERE (c0.`name` = ?) ["Drama"]
%Rumbl.Category{__meta__: #Ecto.Schema.Metadata<:loaded, "categories">, id: 2,
 inserted_at: ~N[2017-09-05 14:24:45.000000], name: "Drama",
 updated_at: ~N[2017-09-05 14:24:45.000000]}

# 取得一個video
iex(8)> video = Repo.one(from v in Video, limit: 1)
[debug] QUERY OK source="videos" db=0.5ms
SELECT v0.`id`, v0.`url`, v0.`title`, v0.`description`, v0.`user_id`, v0.`category_id`, v0.`inserted_at`, v0.`updated_at` FROM `videos` AS v0 LIMIT 1 []
%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
 category: #Ecto.Association.NotLoaded<association :category is not loaded>,
 category_id: 2, description: "test", id: 2,
 inserted_at: ~N[2017-09-05 14:06:33.000000], title: "test",
 updated_at: ~N[2017-09-06 02:09:40.000000], url: "test",
 user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}


# 修改 category_id
iex(10)> changeset = Video.changeset(video, %{category_id: category.id})
[debug] params: %{category_id: 2}
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.Video<>,
 valid?: true>

# 寫入 DB
iex(12)> Repo.update(changeset)
{:ok,
 %Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:loaded, "videos">,
  category: #Ecto.Association.NotLoaded<association :category is not loaded>,
  category_id: 2, description: "test", id: 2,
  inserted_at: ~N[2017-09-05 14:06:33.000000], title: "test",
  updated_at: ~N[2017-09-06 02:09:40.000000], url: "test",
  user: #Ecto.Association.NotLoaded<association :user is not loaded>,
  user_id: 1}}
  
# 將 category_id 設定為 12345
iex(13)> changeset = Video.changeset(video, %{category_id: 12345})
[debug] params: %{category_id: 12345}
#Ecto.Changeset<action: nil, changes: %{category_id: 12345}, errors: [],
 data: #Rumbl.Video<>, valid?: true>
 
# update 時發生 error
iex(14)> Repo.update(changeset)
[debug] QUERY OK db=0.1ms
begin []
[debug] QUERY ERROR db=201.6ms
UPDATE `videos` SET `category_id` = ?, `updated_at` = ? WHERE `id` = ? [12345, {{2017, 9, 6}, {2, 12, 11, 984209}}, 2]
[debug] QUERY OK db=0.4ms
rollback []
{:error,
 #Ecto.Changeset<action: :update, changes: %{category_id: 12345},
  errors: [category: {"does not exist", []}], data: #Rumbl.Video<>,
  valid?: false>}



Delete


$ iex -S mix
iex(1)> alias Rumbl.Repo
Rumbl.Repo
iex(2)> category = Repo.get_by Rumbl.Category, name: "Drama"
[debug] QUERY OK source="categories" db=2.5ms decode=3.3ms
SELECT c0.`id`, c0.`name`, c0.`inserted_at`, c0.`updated_at` FROM `categories` AS c0 WHERE (c0.`name` = ?) ["Drama"]
%Rumbl.Category{__meta__: #Ecto.Schema.Metadata<:loaded, "categories">, id: 2,
 inserted_at: ~N[2017-09-05 14:24:45.000000], name: "Drama",
 updated_at: ~N[2017-09-05 14:24:45.000000]}
iex(3)> Repo.delete category
[debug] QUERY ERROR db=1.9ms
DELETE FROM `categories` WHERE `id` = ? [2]
** (Ecto.ConstraintError) constraint error when attempting to delete struct:

    * foreign_key: videos_category_id_fkey

If you would like to convert this constraint into an error, please
call foreign_key_constraint/3 in your changeset and define the proper
constraint name. The changeset has not defined any constraint.

    (ecto) lib/ecto/repo/schema.ex:570: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
    (elixir) lib/enum.ex:1255: Enum."-map/2-lists^map/1-0-"/2
    (ecto) lib/ecto/repo/schema.ex:555: Ecto.Repo.Schema.constraints_to_errors/3
    (ecto) lib/ecto/repo/schema.ex:382: anonymous fn/9 in Ecto.Repo.Schema.do_delete/4

delete 也可以用 changeset 當參數


iex(3)> import Ecto.Changeset
Ecto.Changeset
iex(4)> changeset = Ecto.Changeset.change(category)
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.Category<>,
 valid?: true>
iex(5)> changeset = foreign_key_constraint(changeset, :videos, name: :videos_category_id_fkey, message: "still exist")
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.Category<>,
 valid?: true>

iex(6)> Repo.delete changeset
[debug] QUERY ERROR db=5.1ms
DELETE FROM `categories` WHERE `id` = ? [2]
{:error,
 #Ecto.Changeset<action: :delete, changes: %{},
  errors: [videos: {"still exist", []}], data: #Rumbl.Category<>,
  valid?: false>}

在 DB migration 裡,references 裡面可加上 :on_delete option


  • :nothing


    預設值

  • :delete_all


    刪除某個 category 時,所有這個 category 的 videos 都會被刪除

  • :nilify_all


    刪除某個 cateogry 時,所有這個 category 的 videos 的 category_id 會被設定為 null


ex:


add :category_id, references(:categories, on_delete: :nothing)

References


Programming Phoenix

2018/10/1

Phoenix_4_UserAuthentication


如何處理 login form, session, password hashing。


Preparing for Authentication


User 在註冊時要提供 credentials (通常就是密碼),然後用加密的方式儲存在 DB 中。當 user 透過 credentials 登入後,要產生一個 session,然後就被授權可以存取資料,直接 session expired 或是 logout。


我們可以利用 comeonin 這個 package 處理 hashing 的功能,comeonin 可選用 Argon2, Bcrypt and Pbkdf2 (sha512 and sha256) 任何一種加密演算法,在範例裡是使用 Bcrypt。


修改 /rumbl/mix.exs,增加 :comeonin :bcrypt_elixir 兩個packages,同時在 applications 裡面加上 :comeonin。


  def application do
    [mod: {Rumbl, []},
     applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,
                    :phoenix_ecto, :mariaex, :comeonin]]
  end
  
  defp deps do
    [{:phoenix, "~> 1.2.5"},
     {:phoenix_pubsub, "~> 1.0"},
     {:phoenix_ecto, "~> 3.0"},
     {:mariaex, ">= 0.0.0"},
     {:phoenix_html, "~> 2.6"},
     {:phoenix_live_reload, "~> 1.0", only: :dev},
     {:gettext, "~> 0.11"},
     {:comeonin, "~> 4.0"},
     #{:argon2_elixir, "~> 1.2"},
     {:bcrypt_elixir, "~> 1.0"},
     #{:pbkdf2_elixir, "~> 0.12"},
     {:cowboy, "~> 1.0"}]
  end

取得並編譯 deps


$ mix deps.get
$ mix deps.compile

$ mix phoenix.server

Managing Registration Changesets


先前在 /web/models/user.ex 填寫了 changeset


  def changeset(model, params \\ %{}) do
    model
    |> cast(params, ~w(name username), [])
    |> validate_length(:username, min: 1, max: 20)
  end

Ecto.Changeset.cast 是用來轉換 map 為 changeset,另外為了資安考量,可限制 inbound parameters 一些條件。


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 \\ %{}) do
    model
    |> cast(params, ~w(name username), [])
    |> validate_length(:username, min: 1, max: 20)
  end

  # 用上一個 changeset 處理非敏感資料,另外寫這個 changeset 處理密碼
  def registration_changeset(model, params) do
    model
    |> changeset(params)
    |> cast(params, ~w(password), [])
    |> validate_length(:password, min: 6, max: 100)
    |> put_pass_hash()
  end

  # 取出 changeset 裡面的 password 將他以 comeonin 加密後,儲存到 password_hash
  # 回傳新的 changeset
  defp put_pass_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
        put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(pass))
      _ ->
        changeset
    end
  end
end

測試


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

# 密碼太短,不滿足密碼的條件,所以 valid? 為 false
iex(2)> changeset = User.registration_changeset(%User{}, %{ username: "max", name: "Max", password: "123" })
#Ecto.Changeset<action: nil,
 changes: %{name: "Max", password: "123", username: "max"},
 errors: [password: {"should be at least %{count} character(s)",
   [count: 6, validation: :length, min: 6]}], data: #Rumbl.User<>,
 valid?: false>
 
iex(3)> changeset.valid?
false
iex(4)> changeset.changes
%{name: "Max", password: "123", username: "max"}


# 把密碼加長,valid? 為 true
iex(5)> changeset = User.registration_changeset(%User{}, %{ username: "max", name: "Max", password: "123456" })
#Ecto.Changeset<action: nil,
 changes: %{name: "Max", password: "123456",
   password_hash: "$2b$12$ZQ2vLBhdeanadLKHysBctuY9e2jlhWBciBvyM2rAPh5FtUmY0h6IC",
   username: "max"}, errors: [], data: #Rumbl.User<>, valid?: true>

iex(6)> changeset.valid?                                                                                    true
iex(7)> changeset.changes                                                                                   %{name: "Max", password: "123456",
  password_hash: "$2b$12$ZQ2vLBhdeanadLKHysBctuY9e2jlhWBciBvyM2rAPh5FtUmY0h6IC",
  username: "max"}

用以下這個方式,把所有 users 的 password_hash 填上測試用密碼


for u <- Rumbl.Repo.all(User) do
    Rumbl.Repo.update!(User.registration_changeset(u, %{
        password: u.password_hash || "temppass"
    }))
end


iex(15)> for u <- Rumbl.Repo.all(User) do
...(15)> Rumbl.Repo.update!(User.registration_changeset(u, %{
...(15)> password: u.password_hash || "temppass"
...(15)> }))
...(15)> end
[debug] QUERY OK source="users" db=17.9ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 []
[debug] QUERY OK db=5.9ms
UPDATE `users` SET `password_hash` = ?, `updated_at` = ? WHERE `id` = ? ["$2b$12$whoMkt3Va91Mk6yAmaM0sO/3dgh3nhNCem0M6xMB5UIMXo7vERw6O", {{2017, 9, 5}, {1, 22, 52, 877576}}, 1]
[debug] QUERY OK db=2.5ms
UPDATE `users` SET `password_hash` = ?, `updated_at` = ? WHERE `id` = ? ["$2b$12$pIb.b4jFbzBLvxT7Ud9LA.w.dtPyx9yvung/Z2/Ii5JSnD8E3CaK.", {{2017, 9, 5}, {1, 22, 53, 233596}}, 2]
[debug] QUERY OK db=2.4ms
UPDATE `users` SET `password_hash` = ?, `updated_at` = ? WHERE `id` = ? ["$2b$12$soPEIgY.vm6HJi/WTfcF3uHevDkqj.yBV0cW7mOHFnZdiAo3LJ.tK", {{2017, 9, 5}, {1, 22, 53, 589897}}, 3]
[%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
.....

Create Users


修改 /web/controllers/usercontroller.ex 的 create,改成使用 registrationchangeset


def create(conn, %{"user" => user_params}) do
    changeset = User.registration_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

Plug for authentication


有兩種 plugs


  1. module plugs
    兩個 functions with some configuration details
  2. function plugs
    單一 function

可在 /lib/rumbl/endpoint.ex 裡面看到有使用 module plug


plug Plug.Logger

在 /web/router.ex 裡面看到有使用 function plug


  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end



Module Plugs


有時會需要分享 plug 給不同 modules 使用,這種狀況就要使用 module plug。


module plug 必須要有兩個 functions: init 及 call,以下這是一個最簡單的 module plug。


defmodule NothingPlug do
    def init(opts) do
        opts
    end
    
    def call(conn, _opts) do
        conn
    end
end

通常 plug 會 transform a connection,主要工作是在 call 裡面實作。 init 是在 compile time 處理,提供 transform options。


init 會在 compilation time 被呼叫,這是驗證 options 並做一些準備工作的地方。


module 及 function plugs 的 request interface 都一樣,第一個參數 conn,是傳入 plug 的資料,裡面有 request 的相關詳細資料,可以在 plug 改變它,所有 plugs 都是以 conn 為參數,結束時回傳 conn。


connections 簡寫為 conn,他是 Plug.Conn struct,這是 Plug 的基礎。




Plug.Conn Fields: 裡面有關於 web request, response 所有資訊


Phoenix 是使用 Cowboy 為 web server,但也可以改用其他的 web server。


  1. host
  2. method


    request method, GET/POST

  3. path_info


    splits into List, ex: ["admin", "users"]

  4. script_name


    initial portion of the URL's path, 關於 application routing 的部分, 例如 ["sub", "app"]

  5. port

  6. peer


    TCP peer ex: {{127,0,0,1},12345}

  7. remote_ip


    {192,168,1,1},如果經過HAProxy,會變成 Forwarded-For 的 header 欄位

  8. req_headers


    request headers, ex: [{"content-type", "text/plain"}],這些 header 都會是小寫字母

  9. scheme


    request protocol, :https

  10. query_string


    request query string as binary, ex: "param1=test"




下個部分是 fetchable fields


fetchable field 會在取用時,才會即時運算出來,所以需要一點運算時間,如果沒有取用就會是空的。


  1. cookies


    request + response cookies

  2. body_params


    request body params,透過 Plug.Parsers 產生的

  3. query_params


    request query params,透過 fetchqueryparams/2 產生的

  4. path_params


    requestpathparams,透過 Plug.Router 產生的

  5. params


    request params,包含 :bodyparams + :queryparams + :path_params

  6. req_cookies


    request cookies 不含 response cookie




接下來是用來處理 web requests 的欄位,可用來 encrypt cookies,處理 user-defined functions


  1. assigns


    shared user data as a map,user 自訂儲存資料的 map

  2. owner


    保存這個 connection 的 Elixir process

  3. halted


    是否有被 pipeline 中止的 boolean status flag,例如 authorization failed

  4. secretkeybase


    用來 verify and encrypt cookies 的 secret key,可用 Plug.Crypto.KeyGenerator.generate/3 產生 keys

  5. state


    conenction state :set, :sent




因為 Plug 會處理任一個 request 的整個過程,包含 request 及 response


Plug.Conn 針對 response 提供了以下這些欄位


  1. resp_body


    response body 預設為空字串,會在 reponse 發送後,設定為 nil (除非是 test connections)

  2. resp_charset


    response charset,預設為 "utf-8"

  3. resp_cookies


    response cookies with name and options

  4. resp_headers


    response headers as a list of tuples,預設 cache-control 設定為 "max-age=0, private, must-revalidate"

  5. status


    response status




Plug 支援保留給 adapter 及 framework 使用的 private fileds


  1. adapter


    儲存 adapter information in a tuple

  2. private


    shared library data as a map


Writing an Authentication Plug


authentication process 會有兩個步驟,首先會在 user 註冊或登入時,在 session 儲存 user ID,第二步,會檢查 session 是否有新 user,並在每一次 request 都儲存在 conn.assigns,可在 controllers 及 views 裡面使用這些資訊。


新增 /web/controllers/auth.ex


defmodule Rumbl.Auth do
  import Plug.Conn

  # 由 repository 取得 options
  def init(opts) do
    # Keyword.fetch! 會在找不到 key 時,raises an exception
    # Rumbl.Auth 會取用 :repo option
    Keyword.fetch!(opts, :repo)
  end

  # 收到 init 的 repository
  def call(conn, repo) do
    # 檢查 session 是否有存在 :user_id
    user_id = get_session(conn, :user_id)
    # 如果有 user_id 且 User DB 有這個 user_id
    # 利用 assign 把這個 user 資料存放在 conn.assigns
    user    = user_id && repo.get(Rumbl.User, user_id)
    # 後面可以用 :current_user 取得 User 資料
    assign(conn, :current_user, user)
  end
end

新增 plug 到 router,放在 browser pipeline 的最後一個


/web/router.ex


  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug Rumbl.Auth, repo: Rumbl.Repo
  end



Restricting Access


Rumbl.Auth plug 現在會處理 request information 並轉換 conn,增加 :current_user 到 conn.assigns,後面的 plugs 就可以判斷 user 是不是已經登入了。


如果不希望 user 在登入前就能使用 Rumbl.UserController 的 :index 及 :show


要修改 :index 並增加 authenticate(conn)


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

  # 取得所有 users
  def index(conn, _params) do
    case authenticate(conn) do
      %Plug.Conn{halted: true} = conn ->
        conn
      conn ->
        users = Repo.all(User)
        render conn, "index.html", users: users
    end
  end

  # 用 halt() 中止後面的 transformations
  defp authenticate(conn) do
    if conn.assigns.current_user do
      conn
    else
      conn
      |> put_flash(:error, "You must be logged in to access that page")
      |> redirect(to: page_path(conn, :index))
      |> halt()
    end
  end
end

在瀏覽頁面 http://localhost:4000/users 時,就會出現





將剛剛的 authenticate 調整一下,就可以變成 function plug


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

  plug :authenticate when action in [:index, :show]

  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

  # 用 halt() 中止後面的 transformations
  defp authenticate(conn, _opts) do
    if conn.assigns.current_user do
      conn
    else
      conn
      |> put_flash(:error, "You must be logged in to access that page")
      |> redirect(to: page_path(conn, :index))
      |> halt()
    end
  end
end

在瀏覽頁面 http://localhost:4000/users 以及 http://localhost:4000/users/1 時,就會出現一樣的錯誤訊息。




Plug pipelines 會在每一個 plug 執行中間,檢查 halted:true,以便中止後續的 plugs。


如果是這樣的 plugs


plug :one
plug Two
plug :three, some: :option

會編譯為


case one(conn, []) do
    %{halted: true} = conn -> conn
    conn ->
        case Two.call(conn, Two.init([])) do
            %{halted: true} = conn -> conn
            conn ->
                case three(conn, some: :option) do
                    %{halted: true} = conn -> conn
                    conn -> conn
                end
        end
end

Login


/web/controllers/auth.ex 增加 login function


defmodule Rumbl.Auth do
  import Plug.Conn

  # Plug.Conn struct 有個欄位 assigns,可用 assign 設定
  # 將 user 設定為 :current_user
  # 把 user.id 放到 session
  # 設定 :renew option 為 true,configure_session 很重要,他會發送 session cookie 給 client
  def login(conn, user) do
    conn
    |> assign(:current_user, user)
    |> put_session(:user_id, user.id)
    |> configure_session(renew: true)
  end
end

修改 /web/controllers/user_controller.ex,登入成功後,馬上呼叫 Rumbl.Auth.login(user)


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

以瀏覽器 http://localhost:4000/users/new ,註冊一個新 user,就可以轉換到 http://localhost:4000/users 畫面,因為註冊時,馬上就登入了。


Login and Logout


/web/router.ex 增加 resources "/sessions", SessionController, only: [:new, :create, :delete]


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

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

    # GET /sessions/new 產生 new session login form
    # POST /sessions 用來 login
    # DELETE /sessions/:id 用來 logout
    resources "/sessions", SessionController, only: [:new, :create, :delete]
  end

/web/controller/session_controller.ex


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

  # 產生 login form
  def new(conn, _) do
    render conn, "new.html"
  end

  def create(conn, %{"session" => %{"username" => user, "password" =>
    pass}}) do
    case Rumbl.Auth.login_by_username_and_pass(conn, user, pass, repo:
      Repo) do
      {:ok, conn} ->
        conn
        |> put_flash(:info, "Welcome back!")
        |> redirect(to: page_path(conn, :index))
      {:error, _reason, conn} ->
        conn
        |> put_flash(:error, "Invalid username/password combination")
        |> render("new.html")
    end
  end
  
  def delete(conn, _) do
    conn
    |> Rumbl.Auth.logout()
    |> redirect(to: page_path(conn, :index))
  end
end

/web/controllers/auth.ex 增加 loginbyusernameandpass 及 logout


defmodule Rumbl.Auth do

  # 刪除 session
  def logout(conn) do
    configure_session(conn, drop: true)
  end

  # 使用 Bcrypt 的兩個 methods
  import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]

  # 取得 :repo option,以 get_by 方式取得 user,比對 passoword_hash
  def login_by_username_and_pass(conn, username, given_pass, opts) do
    repo = Keyword.fetch!(opts, :repo)
    user = repo.get_by(Rumbl.User, username: username)

    cond do
      user && checkpw(given_pass, user.password_hash) ->
        {:ok, login(conn, user)}
      user ->
        # 密碼錯誤 回傳 :unauthorized
        {:error, :unauthorized, conn}
      true ->
        # 用 comeonin's dummy_checkpw() 模擬 動態時間長度 的 password check
        # 避免 timing attacks
        # 找不到 user 回傳 :not_found
        dummy_checkpw()
        {:error, :not_found, conn}
    end
  end
end

新增 /web/views/session_view.ex


defmodule Rumbl.SessionView do
  use Rumbl.Web, :view
end

新增 /web/templates/session/new.html.eex


<h1>Login</h1>

<%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %>
  <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 "Log in", class: "btn btn-primary" %>
<% end %>

http://localhost:4000/sessions/new 如果帳號密碼填錯,就會出現錯誤訊息



修改 /web/templates/layout/app.html.eex 的 header 區塊,把登入的 username 資訊顯示出來,未登入就顯示 login form 的連結


<div class="header">
        <ol class="breadcrumb text-right">
          <%= if @current_user do %>
            <li><%= @current_user.username %></li>
            <li>
              <%= link "Log out", to: session_path(@conn, :delete, @current_user),
                                  method: "delete" %>
            </li>
          <% else %>
            <li><%= link "Register", to: user_path(@conn, :new) %></li>
            <li><%= link "Log in", to: session_path(@conn, :new) %></li>
          <% end %>
        </ol>
        <span class="logo"></span>
      </div>

References


Programming Phoenix

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

2018/9/10

Phoenix_2_Controller_View_Template


首先 create 新的 project rumbl


$ mix phoenix.new rumbl --database mysql

修改 config/dev.exs 的 DB 設定,然後 create database


$ mix ecto.create

啟動 server


mix phoenix.server

或是


iex -S mix phoenix.server

網址在 http://localhost:4000


調整首頁 /web/templates/page/index.html.eex


<div class="jumbotron">
  <h2>Welcome to Rumbl.io</h2>
  <p class="lead">Rumble out loud.</p>
</div>

Create User


系統通常會以 User 開發為起點。首先定義 Rumbl.User module,包含欄位 id, name, username, password


/web/models/user.ex


defmodule Rumbl.User do
  defstruct [:id, :name, :username, :password]
end



為什麼要使用 struct 而不是 map 定義 User model?


使用 map,在撰寫 model 定義,並不會在編譯時發現有打錯字的狀況,另外也不能為所有欄位都填上預設值。


在 project folder 直接測試


$ iex -S mix
Generated rumbl app

# 可以使用 User
iex(1)> alias Rumbl.User
Rumbl.User

# 以 Map 定義 user
iex(2)> user = %{usernmae: "jose", password: "elixir"}
%{password: "elixir", usernmae: "jose"}

# 因為剛剛定義 user map 時,把 username 打錯了
iex(3)> user.username
** (KeyError) key :username not found in: %{password: "elixir", usernmae: "jose"}

# 如果用 User struct 就不會發生這個問題,還將其他欄位都填上預設值 nil
iex(3)> jose = %User{name: "Jose"}
%Rumbl.User{id: nil, name: "Jose", password: nil, username: nil}
iex(4)> jose.name
"Jose"

# 編譯時就會檢查欄位名稱
iex(5)>  chris = %User{nmae: "chris"}
** (KeyError) key :nmae not found in: %Rumbl.User{id: nil, name: nil, password: nil, username: nil} ....

# struct 就跟 map 一樣,差別只在於 struct 是一個擁有 __struct__ key 的 map
iex(5)> jose.__struct__
Rumbl.User

Working with Repositories


Repository 是一組儲存資料的 API,可快速建立測試資料的 data interface,待完成 view, template 後,就可以替換為完整的 database-backend repository。換句話說,可以將 data model 跟 database 完全分離。


修改 /lib/rumbl/repo.ex


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

  # 所有 User 的資料
  def all(Rumbl.User) do
    [
      %Rumbl.User{id: "1", name: "Jose", username: "josie", password: "elixir"},
      %Rumbl.User{id: "2", name: "Bruce", username: "bruce", password: "pass"},
      %Rumbl.User{id: "3", name: "Chris", username: "chris", password: "phx"}
    ]
  end

  def all(_module), do: []

  # get user by id
  def get(module, id) do
    Enum.find all(module), fn map -> map.id == id end
  end

  # get user by a custom attribute
  def get_by(module, params) do
    Enum.find all(module), fn map ->
      Enum.all?(params, fn {key, val} -> Map.get(map, key) == val end)
    end
  end

end

將 /lib/rumbl.ex,關掉跟 Ecto 相關的 supervisor


#      supervisor(Rumbl.Repo, []),

檢查 User 的測試資料庫


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

iex(3)> Repo.all User
[%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"},
 %Rumbl.User{id: "2", name: "Bruce", password: "pass", username: "bruce"},
 %Rumbl.User{id: "3", name: "Chris", password: "phx", username: "chris"}]
 
iex(4)> Repo.all Rumbl.Other
[]

iex(5)> Repo.get User, "1"
%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"}

iex(6)> Repo.get_by User, name: "Bruce"
%Rumbl.User{id: "2", name: "Bruce", password: "pass", username: "bruce"}

撰寫 Controller & View


修改 /web/router.ex,將 /users 及 /users/:id 對應到 UserController 的 :index 及 :show 兩個 functions


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

    get "/users", UserController, :index
    get "/users/:id", UserController, :show
    get "/", PageController, :index
  end

新增 /web/controllers/user_controller.ex


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

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

end

/web/views/user_view.ex


defmodule Rumbl.UserView do
  use Rumbl.Web, :view

  alias Rumbl.User
  def first_name(%User{name: name}) do
    name
    |> String.split(" ")
    |> Enum.at(0)
  end

end

/web/templates/user/index.html.eex


<h1>Listing Users</h1>
<table class="table">
    <%= for user <- @users do %>
    <tr>
        <td><b><%= first_name(user) %></b> (<%= user.id %>)</td>
        <td><%= link "View", to: user_path(@conn, :show, user.id) %></td>
    </tr>
    <% end %>
</table>

因為剛剛有修改 /lib/rumbl/Repo.ex,因為 lib 目錄更新程式後不會自動 reload,記得要重新啟動 phoenix.server,修改後的程式才會有作用。


mix phoenix.server


Using Helpers


link function 封裝了很多有用的 functions,可用來處理很多 HTML structures。


link 的第二個參數是 keyword list


iex(1)> Phoenix.HTML.Link.link("Home", to: "/")
{:safe, [60, "a", [[32, "href", 61, 34, "/", 34]], 62, "Home", 60, 47, "a", 62]}

iex(2)> Phoenix.HTML.Link.link("Delete", to: "/", method: "delete")
{:safe,
 [60, "a",
  [[32, "data-csrf", 61, 34,
    "DSMgIAMOCzQqQTczRR0/ElEUFDIJJgAA8KZynKSYp9CZ0/MYaNCwDA==", 34],
   [32, "data-method", 61, 34, "delete", 34], [32, "data-to", 61, 34, "/", 34],
   [32, "href", 61, 34, "#", 34], [32, "rel", 61, 34, "nofollow", 34]], 62,
  "Delete", 60, 47, "a", 62]}

HTML helper 會放在每個 view 的最上層,Phoenix.HTML 負責處理 HTML functions in views


/web/web.ex 裡面 view 的區塊內容為


  def view do
    quote do
      use Phoenix.View, root: "web/templates"

      # Import convenience functions from controllers
      import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1]

      # Use all HTML functionality (forms, tags, etc)
      use Phoenix.HTML

      import Rumbl.Router.Helpers
      import Rumbl.ErrorHelpers
      import Rumbl.Gettext
    end
  end

ref: Phoenix.HTML Helpers for working with HTML strings and templates.


不能把自己做的 function 直接寫在 web.ex,要用 import 的方式處理


Showing a User


剛剛在 /web/router.ex 有定義這個 url route


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

修改 /web/controllers/user_controller.ex,增加 show function,注意參數的部分為 %{"id" => id},這是從網址來的


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

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

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

新增 /web/templates/user/show.html.eex


<h1>Showing User (<%= @user.id %>)</h1>
<b><%= first_name(@user) %></b> (<%= @user.id %>)'s username is <b><%= @user.username %></b>

http://localhost:4000/users/1





Naming Conventions


當 Phoenix 在 controller 要 render templates 時,他會使用 controler module 名稱 Rumbl.UserController 參考到 view module 的名稱為 Rumbl.UserView,template 目錄在 /web/templates/user/。


在後面可知道如何 customize 這些 conventions




Nesting Templates,共用 template


新增 /web/templates/user/user.html.eex


<b><%= first_name(@user) %></b> (<%= @user.id %>)'s username is <b><%= @user.username %></b>

修改 /web/templates/user/show.html.eex


<h1>Showing User (<%= @user.id %>)</h1>
<%= render "user.html", user: @user %>

修改 /web/templates/user/index.html.eex


<h1>Listing Users</h1>
<table class="table">
    <%= for user <- @users do %>
    <tr>
        <td><%= render "user.html", user: user %></td>
        <td><%= link "View", to: user_path(@conn, :show, user.id) %></td>
    </tr>
    <% end %>
</table>

<%= render "user.html", user: user %> 這就是共用的部分


以 iex -S mix 測試 view


$ iex -S mix
iex(1)> user = Rumbl.Repo get Rumbl.User, "1"
** (SyntaxError) iex:1: syntax error before: get

iex(1)> user = Rumbl.Repo.get Rumbl.User, "1"
%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"}
iex(2)> view = Rumbl.UserView.render("user.html", user: user)
{:safe,
 [[[[[[["" | "<b>"] | "Jose"] | "</b> ("] | "1"] | ")'s username is <b>"] |
   "josie"] | "</b>"]}
iex(3)> Phoenix.HTML.safe_to_string(view)
"<b>Jose</b> (1)'s username is <b>josie</b>"



每個 template 最後都會變成 render(template_name, assigns),因此 rendering template 就是 template name 及 function 的 pattern matching。


我們可以跳過整個 template 機制,自訂自己的 render clause,這也是 Rumbl.ErrorView 提供的自訂錯誤頁面的方式。


/web/views/error_view.ex


defmodule Rumbl.ErrorView do
  use Rumbl.Web, :view

  def render("404.html", _assigns) do
    "Page not found"
  end

  def render("500.html", _assigns) do
    "Internal server error"
  end

  # In case no render clause matches or no
  # template is found, let's render it as 500
  def template_not_found(_template, assigns) do
    render "500.html", assigns
  end
end



Phoenix.View module 也提供了 render view 的 functions,包含 a function to render 及 轉換 rendered template 為 string的 function。


Phoenix.View 會呼叫 view 的 render functions。


iex(5)> user = Rumbl.Repo.get Rumbl.User, "1"
%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"}


iex(6)> Phoenix.View.render(Rumbl.UserView, "user.html", user: user)
{:safe,
 [[[[[[["" | "<b>"] | "Jose"] | "</b> ("] | "1"] | ")'s username is <b>"] |
   "josie"] | "</b>"]}


iex(7)> Phoenix.View.render_to_string(Rumbl.UserView, "user.html", user: user)
"<b>Jose</b> (1)'s username is <b>josie</b>"



Layouts


在 controller 呼叫 render 時,controller 會先 render the layout view,然後再依照 predefined markup 去 render the actual template


因為 layout 也是 view with templates,每個 template 會收到一些特定的變數,


查看 /web/templates/layout/app.html.eex 的內容有一段 <%= render @view_module, @view_template, assigns %>1


layouts 就是 HTML templates that embed an action’s HTML


  <body>
    <div class="container">
      <header class="header">
        <nav role="navigation">
          <ul class="nav nav-pills pull-right">
            <li><a href="http://www.phoenixframework.org/docs">Get Started</a></li>
          </ul>
        </nav>
        <span class="logo"></span>
      </header>

      <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
      <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>

      <main role="main">
        <%= render @view_module, @view_template, assigns %>
      </main>

    </div> <!-- /container -->
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>

References


Programming Phoenix


Using MySQL with the Phoenix Framework