顯示具有 Phoenix 標籤的文章。 顯示所有文章
顯示具有 Phoenix 標籤的文章。 顯示所有文章

2018/10/08

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

2018/09/03

Phoenix_1_Installation

Phoenix 是一個 Productive. Reliable. Fast. 的 web framework,以 elixir 實作,運作在 erlang VM 上。

installation

  • erlang vm

  • elixir

  • hex: elixir's package manager

    mix local.hex
  • MySQL/PostgreSQL

  • Node.js for Assets

    必須要 v5.3.0 以上

    node --version
  • Phoenix

    $ mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez
  • mix phoenix.new

    mix 會增加 phoenix.new 的選項

    $ mix -h
    ...
    mix phoenix.new       # Creates a new Phoenix v1.2.5 application

create hello project,預設 DB 是使用 PostgreSQL,我們改用 MySQL

$ mix phoenix.new hello --database mysql

* creating hello/config/config.exs
* creating hello/config/dev.exs
* creating hello/config/prod.exs
* creating hello/config/prod.secret.exs
* creating hello/config/test.exs
* creating hello/lib/hello.ex
* creating hello/lib/hello/endpoint.ex
* creating hello/test/views/error_view_test.exs
* creating hello/test/support/conn_case.ex
* creating hello/test/support/channel_case.ex
* creating hello/test/test_helper.exs
* creating hello/web/channels/user_socket.ex
* creating hello/web/router.ex
* creating hello/web/views/error_view.ex
* creating hello/web/web.ex
* creating hello/mix.exs
* creating hello/README.md
* creating hello/web/gettext.ex
* creating hello/priv/gettext/errors.pot
* creating hello/priv/gettext/en/LC_MESSAGES/errors.po
* creating hello/web/views/error_helpers.ex
* creating hello/lib/hello/repo.ex
* creating hello/test/support/model_case.ex
* creating hello/priv/repo/seeds.exs
* creating hello/.gitignore
* creating hello/brunch-config.js
* creating hello/package.json
* creating hello/web/static/css/app.css
* creating hello/web/static/css/phoenix.css
* creating hello/web/static/js/app.js
* creating hello/web/static/js/socket.js
* creating hello/web/static/assets/robots.txt
* creating hello/web/static/assets/images/phoenix.png
* creating hello/web/static/assets/favicon.ico
* creating hello/test/controllers/page_controller_test.exs
* creating hello/test/views/layout_view_test.exs
* creating hello/test/views/page_view_test.exs
* creating hello/web/controllers/page_controller.ex
* creating hello/web/templates/layout/app.html.eex
* creating hello/web/templates/page/index.html.eex
* creating hello/web/views/layout_view.ex
* creating hello/web/views/page_view.ex

Fetch and install dependencies? [Yn] y
* running mix deps.get
* running npm install && node node_modules/brunch/bin/brunch build

We are all set! Run your Phoenix application:

    $ cd hello
    $ mix phoenix.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phoenix.server

Before moving on, configure your database in config/dev.exs and run:

    $ mix ecto.create

設定 DB 連線資訊

config/dev.exs

config :hello, Hello.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "root",
  password: "password",
  database: "hello",
  hostname: "localhost",
  pool_size: 10

執行 ecto.create

$ mix ecto.create

啟動 phoenix

$ mix phoenix.server
[info] Running Hello.Endpoint with Cowboy using http://localhost:4000
16:42:29 - info: compiled 6 files into 2 files, copied 3 in 1.1 sec

或是用 IEx (Interactive Elixir) 啟動

$ iex -S mix phoenix.server

網址在 http://localhost:4000

Simple tutorial

在 /web/router.ex 裡面有一段 scope 的定義

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

    get "/", PageController, :index
  end

所有以 ./ 開頭的 route 都會符合這個規則,pipe_through :browser macro 負責處理一般 browser 的 requests。

增加 /hello 的網址,指定給另一個 Controller

    get "/hello", HelloController, :world
    get "/", PageController, :index

http://localhost:4000/hello 會出現錯誤訊息

UndefinedFunctionError at GET /hello
function Hello.HelloController.init/1 is undefined (module Hello.HelloController is not available)

新增 /web/controllers/hello/hello_controller.ex

defmodule Hello.HelloController do
  @moduledoc false

  use Hello.Web, :controller
  def world(conn, _params) do
    render conn, "world.html"
  end

end

再瀏覽一次 http://localhost:4000/hello 會出現不同的錯誤訊息

UndefinedFunctionError at GET /hello
function Hello.HelloView.render/2 is undefined (module Hello.HelloView is not available)

新增 /web/views/hello_view.ex

defmodule Hello.HelloView do
  use Hello.Web, :view
end

新增 /web/templates/hello/world.html.eex

<h1>From template: Hello world!</h1>

不需要重新啟動 server,reload 網頁就可以看到結果


修改 web/router.ex,把網址當作變數

get "/hello/:name", HelloController, :world

修改 hello_controller.ex

defmodule Hello.HelloController do
  @moduledoc false

  use Hello.Web, :controller
  # external 參數定義為 "name" => name,但內部卻是用atom,也就是 name: name,這是因為 atom table 不會被 GC。
  def world(conn, %{"name" => name}) do
    render conn, "world.html", name: name
  end

end

修改 world.html.eex

<h1>Hello <%= String.capitalize @name %>!</h1>

瀏覽網址 http://localhost:4000/hello/test


lib 跟 web 資料夾的用途

supervision trees 及 long-running processes 要放在 lib

web-related code 包含 models, views, templates, and controllers 這些放在 web

當 code reloading 功能打開時,web 資料夾裡面的程式在被修改後,會自動 reload,但是 lib 資料夾的程式並不會自動 reload,因此 lib 很適合放 long-running services,例如 Phoenix’s PubSub system, the database connection pool 或是自訂的 supervised processes。

.exs 是 Elixir scripts,不會編譯為 .beam files,因此 mix 設定檔 mix.exs 是用 script 而不是 .ex

Phoenix 支援master configuration 加上其他不同環境的設定檔,所以可看到主設定檔 config.exs 裡面有一段

import_config "#{Mix.env}.exs"

就是說明,這是透過 MIX_ENV 環境變數,判斷 prod/dev/test 三種環境,會嵌入不同環境的設定檔

dev.exs
prod.exs
test.exs

另外 production 環境有一個獨立另外嵌入的設定檔 prod.secret.exs,這是用來儲存 production 環境需要被保護的一些密碼,獨立的檔案可保持設定不會進入 git server。

Endpoint

config.exs 包含了 loggin, endpoint 的設定

Endpoint 是 web server 處理 connection 的介面,設定檔裡面只有一個 Hello.Endpoint

# Configures the endpoint
config :hello, Hello.Endpoint,
  url: [host: "localhost"],
  root: Path.dirname(__DIR__),
  secret_key_base: "g8c9YZ5dYGeA.....XkLz5",
  render_errors: [accepts: ~w(html json)],
  pubsub: [name: Hello.PubSub,
           adapter: Phoenix.PubSub.PG2]

而 /lib/hello/endpoint.ex 中 Hello.Endpoint 裡面包含了這些 chaing of functions 及 plugs

defmodule Hello.Endpoint do
    use Phoenix.Endpoint, otp_app: :hello
    
    plug Plug.Static, ...
    plug Plug.RequestId
    plug Plug.Logger
    plug Plug.Parsers, ...
    plug Plug.MethodOverride
    plug Plug.Head
    plug Plug.Session, ...
    plug Hello.Router
end

這在內部其實是用 pipeline 做的,Endpoints 就是對每一個 request 執行 chain of functions。

connection
    |> Plug.Static.call
    |> Plug.RequestId.call
    |> Plug.Logger.call
    |> Plug.Parsers.call
    |> Plug.MethodOverride.call
    |> Plug.Head.call
    |> Plug.Session.call
    |> Hello.Router.call

Endpoint 也是一個 plug,尤其他 plugs 組合而成。application 是以 series of plugs 組成,由 endpoint開始,以 controller 結束,最後一個 plug 是 controller,也就是 web/router.ex 定義的 controller。

connection
    |> endpoint
    |> plug
    |> plug
    ...
    |> router
    |> HelloController

通常 application 只需要一個 endpoint,但也沒有限制只能有一個,如果要支援 http 80 及 https 443 或是 特別的 8080 for admin,就可以增加 endpoints

Router Flow

web/router.ex 是由兩個部分組成的: pipelines 及 route table

defmodule Hello.Router do
  use Hello.Web, :router

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

  pipeline :api do
    plug :accepts, ["json"]
  end

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

    get "/", PageController, :index
  end

  # Other scopes may use custom stacks.
  # scope "/api", Hello do
  #   pipe_through :api
  # end
end

有時會需要針對不同的網址,提供不同的 set of tasks/transformations,例如 :browser 是針對 HTML,有提供取得 session 的方法,並有一個稱為 flash 的 user message system,這裡也提供 security service,例如 request forgery protection。

第二個 pipeline 為 :api,是針對 JSON 的 API,只會處理 JSON requests,如果像要轉換為只處理 XML,就要修改這裡的 plug。

hello application 只使用了 :browser,是用 pipe_through :browser 指定的。

一般的 Phoenix application 是這樣組成的

connection
|> endpoint
|> router
|> pipeline
|> controller
  • endpoint: functions for every request
  • connection: 會經過 named pipeline,針對幾種主要類型的 request提供一般化的 functions
  • controller: 會呼叫 data model 並透過 view template 產生頁面

Controllers, Views, and Templates

web 目錄為

└── web
    ├── channels
    ├── controllers
    │ ├── page_controller.ex
    │ └── hello_controller.ex
    ├── models
    ├── static
    ├── templates
    │ ├── hello
    │ │ └── world.html.eex
    │ ├── layout
    │ │ └── app.html.eex
    │ ├── page
    │ │ └── index.html.eex
    ├── views
    │ ├── error_view.ex
    │ ├── layout_view.ex
    │ ├── page_view.ex
    │ └── hello_view.ex
    ├── router.ex
    └── web.ex

最底層為 router.ex 及 web.ex,web.ex 定義了整個 application structure。

在後面會提到 channel 的部分。

  • Erlang VM 會提供 application scalability
  • endpoint 能過濾 static request,並 parse request 然後呼叫 router
  • browser pipeline 會處理 "Accept" header,取得 session,也能防止 Cross-Site Request Forgery(CSRF) 的攻擊

hello application 相關的檔案

connection                      # Plug.Conn
    |> endpoint                 # lib/hello/endpoint.ex
    |> browser                  # web/router.ex
    |> HelloController.world    # web/controllers/hello_controller.ex
    |> HelloView.render(        # web/views/hello_view.ex
        "world.html")           # web/templates/hello/world.html.eex

References

Programming Phoenix

Using MySQL with the Phoenix Framework