2021年6月27日 星期日

Elixir Phoenix M | V -> C, Controller Pattern Matching, Repo written, Model relations and migrations

本章紀錄關於 Phoenix, Ecto 建立的架構,關於 Router 與 Controller 的 Pattern Matching 以及建立資料流的方式。


注意,Phoenix 相關的架構都會有一定的時效性,本篇文章是在 2021 用 1.5.9 版,很有可能會遇到不同的寫法,但大致上核心理念是差不多的。


Phoenix 這個角色本身就是一個 MVC 框架,然而它本身就自帶一些指令,可以幫助我們快速的建立好 CRUD API + HTML,而且建立的同時,會連帶建立出 Ecto 專用的資料定義。


建立 Model with Relations


建立資料三大常用指令:

1. gen.html: 會建立出 CRUD HTML + Controller + Ecto CRUD Model API  以及 Ecto 資料定義

2. gen.context 只會建立出 Ecto CRUD Model API 以及 Ecto 資料定義

3. gen_schema 只會建立出 Ecto 資料定義 


視情況看需不需要增加 View, Controller 內容來決定是否要用這三者其中哪者。


首先,這篇文章要透過建立一個只有標題、內容的文章部落格,以及對每篇文章 (Post) 建立一些附屬的屬性來了解 Data Relations,藉由此 Examples 來變成一個規劃藍圖。


關聯性規劃是這樣的:

*是 [關聯方式] + table 名稱

裡面唯有 belongs_to 會是 DB 資料定義 (foriegn_key),其餘的都會在程式中透過 Ecto 自動幫助你關聯這些資料 (join_through 就會拿自己的 id 去 many-to-many table 比較另外一個 data), has_many, has_one 則也是會拿自己的 id 去對應的 belongs_to 搜尋,只要透過 Repo.Preload 方法呼叫即可。

  • 語言 Languages (一種語言) *languages
    • 有很多文章 *Has-many: posts
  • 文章 Posts (一篇文章) *posts
    • 有很多個類別 Categories *Many-To-Many join_through: post_categories
    • 屬於一種語言 *belongs_to: language
  • 類別 Categories (一個類別) *categories
    • 有很多個文章 Posts *Many-To-Many join_through: posts_categories
  • 很多文章對應很多類別,很多類別對應很多文章 * Many-To-Many: posts_categories


建立 Language


mix phx.gen.html Languages Language languages name:string


關於指令的欄位看法,有兩種記憶方式:

1. mix phx.gen.html [ecto api models 名稱] [struct 資料定義名稱] [資料庫 table 名稱] .....[各種定義]

2. mix phx.gen.html [複數] [單數] [資料表名稱(s複數)] .....[各種定義]


優先建立 Language ,是因為 Post 會需要依賴這個 Language 當作 reference ,省下一點時間另外建立 Relations。


建立 Post


接著,要建立一個文章,文章建立雖然還沒有類別,但可以先把 reference 語言加上去。


mix phx.gen.html Posts Post posts title:string content:string language_id:references:languages


修改 Posts 與 Languages 關聯性

依照剛才規劃的關聯性表,在 /lib/hello/posts/post.ex,修改成這樣的欄位:

defmodule Hello.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "post" do
    field :content, :string
    field :title, :string
    # field :language, :id # 替換成下方的方式
    
    # 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
    belongs_to :language, Hello.Languages.Language, foreign_key: :language_id 


    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content]) # 如果有那些欄位要從 controller 或 ecto model api 加進去,比方說 language_id,那就要在這裡多寫 -> cast(attrs, [:title, :content, :language_id]) 不然他不會新增進去資料庫喔
    |> validate_required([:title, :content]) # 這裡是新增時必要的欄位,如果不再欄位或是資料有少就會噴錯誤告訴你此欄位不能留空
  end
end

Post 已經有建立關聯了,也要把 Language 對應關聯性加上去:

/lib/hello/languages/language.ex:

defmodule Hello.Languages.Language do
  use Ecto.Schema
  import Ecto.Changeset

  schema "languages" do
    field :name, :string

    # 注意這裡是複數個文章 + s,後面帶入 Post 的 struct 結構
    has_many :posts, Hello.Posts.Post

    timestamps()
  end

  @doc false
  def changeset(language, attrs) do
    language
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end


如此一來,我們就建立了 Language 與 Post 關聯性,使用 has_many, belongs_to 的方式進行,但在此我們也可以了解一下 migrations 的資料長怎樣:

hello/priv/repo/migrations/20210626154632_create_post.exs:

defmodule Hello.Repo.Migrations.CreatePost do
  use Ecto.Migration

  def change do
    create table(:post) do
      add :title, :string
      add :content, :string

      # 列為參考 foreign_key 定義
      add :language_id, references(:languages, on_delete: :nothing)

      timestamps()
    end

    create index(:post, [:language_id]) # foreign_key 的 index 要放進來
  end
end



建立 Category


一篇文章有對應很多個 Category,因此在這裡就會碰到一個 Many-To-Many 關聯性的需求出現,設計 many-to-many 的步驟是:

  1. 多一張表,存放兩個資料之間的 id,表的名稱會是: [資料1 複數 s]_[資料 2 複數 s]
    像是這個 chapter 的例子就是: categories_posts。
  2. 手動建立 ecto migration ,自己產生一個表 (不含在任何定義中)
  3. 兩個資料雖然沒有 foreign_key 但是可以寫 associations 的 many_to_many 關聯性定義


所以,現在要先建立 category,再來說 many_to_many 要怎樣建立。


mix phx.gen.html Categories Category categories name:string


這裡只是很單純的建立一個 category,什麼關聯性 references 都不用寫,因為不需要。


接著,要開始設計 Many To Many 表了,要先用 ecto 的指令建立出 migration 的腳本,然後自己編輯新增:


mix ecto.gen.migration create_categories_posts


編輯生產出來的 migration 腳本 hello/priv/repo/migrations/20210626160659_create_categories_posts.exs:

defmodule Hello.Repo.Migrations.CreateCategoriesPosts do
  use Ecto.Migration

  def change do
    # 雙複數,建立一張表
    create table(:categories_posts) do
      add :category_id, references(:categories) # 注意這裡 references 到的是複數 s
      add :post_id, references(:posts) # 注意這裡 references 到的是複數 s
    end

    # 建立單獨的 index, 可 unique 是因為一個文章不會重疊相同的 category 超過 1 次,e.g: #美食 #美食 #美食
    create unique_index(:categories_posts, [:category_id, :post_id])

  end
end

然後,再回到 lib 中看看這兩個表之間的定義要怎樣加入關聯性定義描述。

/lib/hello/posts/post.ex:

defmodule Hello.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :content, :string
    field :title, :string

    # 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
    belongs_to :language, Hello.Languages.Language, foreign_key: :language_id


    # 新增多對多關聯性定義
    # 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
    many_to_many :categories, Hello.Categories.Category, join_through: "categories_posts"


    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content])
    |> validate_required([:title, :content])
  end
end


再看看 category :
/lib/categories/category.ex:

defmodule Hello.Categories.Category do
  use Ecto.Schema
  import Ecto.Changeset

  schema "categories" do
    field :name, :string

    # 新增多對多關聯性定義
    # 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
    many_to_many :posts, Hello.Posts.Post, join_through: "categories_posts"

    timestamps()
  end

  @doc false
  def changeset(category, attrs) do
    category
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end



所以由此就知道,many to many 兩個要加入的關聯性一模一樣,只是互相用的人不同。


要針對這個 Many To Many 做簡易測試,則是用 SQL 搭配 iex 互動命令確認剛才定義的 struct  relation 中,在 iex 是否有 preload 出來資料。

測試 SQL (還沒有講到程式建立的部分,於是手動建立):
INSERT INTO categories(name, inserted_at, updated_at) VALUES('Programming', NOW(), NOW()); 
INSERT INTO categories(name, inserted_at, updated_at) VALUES('Kitchen', NOW(), NOW());
INSERT INTO posts(title, content,inserted_at, updated_at) VALUES('This is a Programming book', 'Book content', NOW(), NOW());
INSERT INTO posts(title, content,inserted_at, updated_at) VALUES('This is a Kitchen book', 'Book content', NOW(), NOW());
INSERT INTO categories_posts(category_id, post_id) VALUES(2, 1);
INSERT INTO categories_posts(category_id, post_id) VALUES(3, 1);


建立好後,用 iex -S mix 測試:

alias Hello.Categories
alias Hello.Repo
Categories.get_category!(2) # 發現 post 寫 not loaded
Categories.get_category!(2) |> Repo.preload([:posts]) # 發現 posts 都被讀到了



於是,實際 many to many 的建立方式並不複雜,僅此而已。


Ecto Model API 層的操作法


什麼是 Ecto Model API?  在 Phoenix 專案架構中,可以透過以下結構說明來理解 lib 有什麼:

  • lib
    • hello <- 很單純的資料定義、資料操作,資料操作就是 Ecto Model API
      • (目錄) categories
        • category.ex <- 資料定義 (資料庫、changeset、插入資料要檢查、轉型)
      • (目錄) languages
        • language.ex <- 資料定義
      • (目錄) posts
        • post.ex  <- 資料定義
      • (檔案) categories  <- Ecto Model API: 包含 CRUD 操作、自訂操作
      • (檔案) languages <- Ecto Model API
      • (檔案) posts <- Ecto Model API
      • ....略
    • hello_web <- Controller, Views  以及 Router 各種 web 物件都放在這裡
      • ...略


由此大致可知,你的 Model 單數為檔名的檔案,會用來處理資料庫定義、資料轉換 (如明文密碼轉 hash、cast、changeset)。

複數為檔名的檔案,會用來處理 Repo 執行、查詢、寫 query 查詢、或做外部資料處理、read file、write file、send email、send sms、add queue job 等等。

在資料定義中,如果是寫 accounts 等帳號密碼使用者定義,還可以找到有 field 含有 virtual: true 屬性,讓資料不會寫進資料庫,而是要透過 changeset 之前,把 virtual 自己做密碼 hash,然後傳到真正的資料庫欄位,再寫入。

比方說帳號密碼會是這樣做:

  • field passowrd, :string, virtual: true < 不會寫入資料庫,待轉換成 hash
  • field password_hash, :string   <- 會寫入資料庫,不過你要自己轉換寫入



在 Model API 中依照 Category id 列出 Post (關聯性操作)


在 /lib/hello/posts.ex 檔案中,下面加入一個 function:

def list_post_by_category(category_id) do
    # ^ pin 運算子是為了讓 category_id 變數變成一個確切固定的顯值,而且再也不會變動,已不是"變"數
    query = from p in Post,
              join: cp in "categories_posts",
              on: cp.category_id == ^category_id,
              distinct: p.id, # 選取不重複的 post id
              select: p
    Repo.all(query)
end

然後,在 iex -S mix 中,就可以這麼呼叫:

Hello.Posts.list_post_by_category(2)


建立 Many-To-Many 的 Category 與 Post 綁定


要幫 Post 加上各種 category 分類,可以這麼做:

在 /lib/hello/posts.ex 檔案中,在 create_post 這附近做修改:

def create_post(attrs \\ %{}) do
    %Post{}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

# 新增
# multiple select
def bind_post_categories(post_id, category_ids) do
  for c_id <- category_ids do
    Repo.insert_all "categories_posts", [ %{
      "category_id"=> c_id,
      "post_id" => post_id
    } ], returning: [:id]
  end

  # 另一總做法也可以用 Enum.map()
  # bind_post_with_categories = Enum.map(category_ids, fn c_id ->
  #  { category_id: c_id, post_id: post_id}
  # end)

  #然後丟到 insert_all 後面那個陣列
end

讓 Language id 預設可以帶入 Post 一起新增進資料庫


關鍵點不是在修改 Ecto Model API,而是在 Model 處理,要修改的檔案是
/lib/hello/posts/post.ex:

defmodule Hello.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :content, :string
    field :title, :string

    # 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
    belongs_to :language, Hello.Languages.Language, foreign_key: :language_id


    # 新增多對多關聯性定義
    # 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
    many_to_many :categories, Hello.Categories.Category, join_through: "categories_posts"


    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    # 新增 cast, validate_required
    post
    |> cast(attrs, [:title, :content, :language_id])
    |> validate_required([:title, :content, :language_id])
  end
end


主要是需要新增 cast, validate_required 中的部分,強制呼叫要送資料去新增時,要記得驗證欄位是否有值。



Phoenix Controller / Views


首先,要先針對剛才 generate 出來的 html 做套用到 router 上才會看到視覺化的結果,要修改的是 lib/hello_web/router.ex:

scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/posts", PostController
    resources "/categories", CategoryController
    resources "/languages", LanguageController
end

在這邊要加上三段 resources 可以在網址 localhost:4000/posts, /categories, /languages 看到 CURD

現在,需要關注 /posts/new 新增文章時的行為,在此處看到的東西只有 title, content 兩個欄位,而現在還希望帶入 categories, language select 框框選擇,該如何做?

-> 修改 Controller ,讓某些 router 會帶入這兩個東西的資料,然後在 view 的 html.eex 檔案中就可以用 @____ 得到變數。


也許一般來說可以從 controller 各個畫面的 controller 獨立查詢,但現在有更好的做法,就是用 plug,讓特定行為的 action 進入時,預先載入 function (也可稱作 middleware 吧)。

/lib/hello_web/controllers/post_controller.ex:

這串要加在 def index(... 之前

alias Hello.Categories
alias Hello.Languages

# 在 :new, :edit 的時候,查詢一下 languages 列表
# :loca_categories 是對應到 load_categories 這個 func 
plug :load_languages when action in [:new, :edit]

# 在 :new, :edit 的時候,查詢一下 categories 列表
# :loca_categories 是對應到 load_categories 這個 func 
plug :load_categories when action in [:new, :edit]

defp load_languages(conn, _) do
  languages = Languages.list_languages()
  conn
  |> assign(:languages, languages)
end

defp load_categories(conn, _) do
  categories = Categories.list_categories()
  conn
  |> assign(:categories, categories)
end

在這裡要注意的是 plug 是有順序性的。


現在,在 /posts/new 中雖然還沒有東西,但現在就要加上去了,在 /lib/hello_web/templates/post/form.html.eex 這個檔案看一下:

為什麼要看這個檔案? 因為原本 controller 呼叫的是 /lib/hello_web/templates/post/new.html.eex,可是這個檔案內部有呼叫渲染表單出來。


對這個檔案新增兩個選項出來

/lib/hello_web/templates/post/form.html.eex:

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

  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>

  <%= label f, :content %>
  <%= text_input f, :content %>
  <%= error_tag f, :content %>

  <!-- Enum.map(@languages, &{&1.name, &1.id}) 這個用法是把它變成 { name:XXX, id: XXX } 形式列成列表, & 是 fn x, x 簡化為值的本身,就變成用 & 呼叫-->
  <%= label f, :language_id %>
  <%= select f, :language_id, Enum.map(@languages, &{&1.name, &1.id}), prompt: "Choose Language" %>
  <%= error_tag f, :language_id %>

  <!-- Enum.map(@categories, &{&1.name, &1.id}) 這個用法是把它變成 { name:XXX, id: XXX } 形式列成列表, & 是 fn x, x 簡化為值的本身,就變成用 & 呼叫-->
  <%= label f, :category_ids %>
  <%= multiple_select f, :category_ids, Enum.map(@categories, &{&1.name, &1.id}), prompt: "Choose Category" %>
  <%= error_tag f, :category_ids %>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

接著,需要修改 Controller 接收到額外這兩個參數要做什麼事,首先, language_id 不需要做任何事,因為 Model Struct 中早就會做 cast 把 language_id 轉換,問題應該就會在 categories 要新增出來,怎麼做。

/lib/hello_web/controllers/post_controller.ex:

def create(conn, %{"post" => post_params}) do
    # 把字串轉換為 int
    # ["1", "2"] -> [1, 2]
    category_ids_int = Enum.map(post_params["category_ids"], fn str_id ->
      {int_id, _p} = Integer.parse(str_id)
      int_id # 回傳
    end)

    case Posts.create_post(post_params) do
      {:ok, post} ->
        # 在這裡做文章 category 新增
        Posts.bind_post_categories(post.id, category_ids_int)

        conn
        |> put_flash(:info, "Post created successfully.")
        |> redirect(to: Routes.post_path(conn, :show, post))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
end

如此一來,在 /posts/new 就可以看到各種選項可以被新增了。



列出分類的文章


大致上,就是要讓 Categories 頁面可以 show 出只有包含 category_id 的 post 頁面。


在這裡,嘗試兩種做法來顯示 category:

  1. Query: ?category_id=1 在 Controller 中做 Pattern Matching
  2. Routing: /posts/cate/:category_id 在 Controller 讀取


作法 1


這個方法希望用 /posts?category_id=1 這個方式來顯示,他的做法是在 post_controller.ex 建立 pattern matching 的 index function:

# 注意 pattern matching 有順序性,這個有比對值得要先
  def index(conn, %{ "category_id" => category_id }) do
    # 除錯時可以看到他會不會進來 pattern matching
    IO.puts("=============================================")
    IO.inspect(category_id)
    IO.puts("=============================================")
    # 字串的 categroy_id 應該要轉成數字
    { category_id_int, _p } = Integer.parse(category_id)
    post = Posts.list_post_by_category(category_id_int)
    render(conn, "index.html", post: post)
  end

# 預設不 care params 的要放後面
def index(conn, _params) do
    post = Posts.list_post()
    render(conn, "index.html", post: post)
end

注意,pattern matching 要加在 index 預設值的上方,才有可能會 fall-in,關於 pattern matching,也可以想成是 switch case,如果你提早進入 case 如果你沒有繼續讓 case 往下做,那下面的 case 也不會被 matching 到。

在這裡的例子,你可以想像你的 switch case 的 default 比 case 提早寫,所以會直接進入 default。

直接在網址找: /posts?category_id=2 ,就可以發現他會過濾文章了,而且不加的時候,可以看到第一個 matching 完全不會進去,看終端機有沒有 ===== 就知道了。


然後,還希望可以從 /categories 這個頁面可以連過來這個網站還要附加參數,要怎麼做?

在 /lib/hello_web/templates/cateroy/index.html.eex 中,新增一個 link:

<%= for category <- @categories do %>
    <tr>
      <td><%= category.name %></td>

      <td>
        <span><%= link "Show", to: Routes.category_path(@conn, :show, category) %></span>
        <span><%= link "Edit", to: Routes.category_path(@conn, :edit, category) %></span>
        <span><%= link "Delete", to: Routes.category_path(@conn, :delete, category), method: :delete, data: [confirm: "Are you sure?"] %></span>
        <!-- 注意這裡的 routes 是單數, Routes.____ 有哪些,可以用 mix phx.routes 指令看到 -->
        <!-- 注意,方法一是用 query stirng 當作參數, post_path 最後一個參數要用 map %{ ... } -->
        <span><%= link "Posts", to: Routes.post_path(@conn, :index, %{ "conference_id" => category.id }) %></span>
      </td>
    </tr>
<% end %>

第二個參數 :index, :sohw, :edit 仔細一猜,就可以發現那些都是 Controller Function 名稱。

第三個參數,如果在這裡都不加,就等於是找原始沒有 query string pattern matching 的 controller function。


作法 2


作法二要嘗試的是新增到 Router 去,看能不能用 url params 處理,像是這樣:

/posts/cate/:category_id

由於上面已經用過 Pattern Matching 而且在這裡的做法會一模一樣,因此這裡想換成讓 Router 去執行特定的 Controller: list_by_category。

直接新增到 post_controller:

def list_by_category(conn,  %{ "category_id" => category_id }) do
    # 除錯時可以看到他會不會進來 pattern matching
    IO.puts("=============================================")
    IO.inspect(category_id)
    IO.puts("=============================================")
    # 字串的 categroy_id 應該要轉成數字
    { category_id_int, _p } = Integer.parse(category_id)
    post = Posts.list_post_by_category(category_id_int)
    render(conn, "index.html", post: post)
end

這個 function 跟 index 是完全一模一樣的。


現在,要在 router.ex 中新增一個 router 進來:

scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/posts", PostController
    resources "/categories", CategoryController
    resources "/languages", LanguageController
	
    # 新增
    get "/posts/cate/:category_id", PostController, :list_by_category
end


不過此刻,在 /categories 頁面中,要跳轉過來的方式就完全不同,需要注意 Routes.xxx_path 是不同的,請注意  /lib/hello_web/templates/cateroy/index.html.eex 會像這樣:

<!-- 注意,方法二是用 url params 當作參數, post_path 最後一個參數要依序使用單獨參數 -->
<!-- 換言之,如果你的 rotuer 是 /cate/:category_id/:a/:b ,那你也必須給成: -->
<!-- Routes.post_path(@conn, :list_by_category, category.id, "a", "b" ) -->
<span><%= link "Posts", to: Routes.post_path(@conn, :list_by_category, category.id ) %></span>


然後,在 /categories/ 就可以看到點擊按鈕,會跳到 /posts/cate/2 這樣的 router。


Pipeline 小記:

個人的見解,對於很多  Functions work 呼叫都會有一系列的整合 Function,例如 Controller 本身會做很多的事,而為了 keep code dry,把很多功能拆成 function ,然後在組合的時候,用 pipeline (|>) 把它們的工作串接再一起,也就是用來掩飾複雜工作的一種用法。


Reference:

http://blog.plataformatec.com.br/2016/05/ectos-insert_all-and-schemaless-queries/
https://stackoverflow.com/questions/44879027/how-to-make-scaffold-for-two-entities-relations-with-elixir-phoenix

沒有留言:

張貼留言

© Mac Taylor, 歡迎自由轉貼。
Background Email Pattern by Toby Elliott
Since 2014