變更集 (Changesets)

翻譯內文為最新版本。

為了插入、更新或刪除資料庫中的資料,Ecto.Repo.insert/2update/2delete/2 需要一個變更集作為它們的第一個參數。但什麼是變更集?

幾乎每個開發者都熟悉的工作是檢查輸入的資料是否存在潛在錯誤 - 我們希望在嘗試將資料用於目的之前確保資料處於正確的狀態。

Ecto 提供一個完整的解決方案,以 Changeset 模組的形式處理資料更改和資料結構。 在本課程中,將探討此功能,並在將資料長久保存到資料庫之前了解如何驗證資料的完整性。

目錄

建立第一個變更集

現在來看一個空的 %Changeset{} 結構體:

iex> %Ecto.Changeset{}
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: nil, valid?: false>

如你所見,它有一些可能有用的欄位,但它們目前都是空的。

為了使變更集真正有用,當建立它時,需要提供資料的藍圖。 什麼樣的資料藍圖是比用於建立定義欄位和類型的結構描述(schema)更好?

現在來看一個常見的 User 結構描述:

defmodule User do
  use Ecto.Schema

  schema "users" do
    field(:name, :string)
  end
end

要使用 User 結構描述建立變更集,將使用 Ecto.Changeset.cast/4

iex> Ecto.Changeset.cast(%User{name: "Bob"}, %{}, [:name])
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #User<>,
 valid?: true>

第一個參數是原始資料 — 在這個範例中為空的 %User{} 結構體。 Ecto 足夠聰明,可以根據結構體本身找到結構描述。 第二個參數是想要做出的改變 - 只是一張空映射。 第三個參數是使 cast/4 特殊的原因:它是允許通過的欄位列表,這使我們能夠控制哪些欄位可以更改並保護其餘欄位。

 iex> Ecto.Changeset.cast(%User{name: "Bob"}, %{"name" => "Jack"}, [:name])
 #Ecto.Changeset<
  action: nil,
  changes: %{name: "Jack"},
  errors: [],
  data: #User<>,
  valid?: true
>

iex> Ecto.Changeset.cast(%User{name: "Bob"}, %{"name" => "Jack"}, [])
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #User<>,
 valid?: true>

可以在第二次更改時看到如何忽略新名稱,因新名稱未被明確允許。

一個 cast/4 的替代是 change/2 函數,它不能像 cast/4 這樣篩選更改。 不過當進行更改來源是可信任或手動處理資料時,它非常有用。

現在可以建立變更集,但由於沒有驗證,因此將接受對使用者名稱的任何更改,最終會得到一個空名稱:

iex> Ecto.Changeset.cast(%User{name: "Bob"}, %{"name" => ""}, [:name])
#Ecto.Changeset<
 action: nil,
 changes: %{name: ""},
 errors: [],
 data: #User<>,
 valid?: true
>

Ecto 說變更集是有效的,但實際上,我們不想允許空名稱。現在來解決這個問題!

驗證

Ecto 附帶了許多內建的驗證函數來幫助我們。

我們將經常使用 Ecto.Changeset,所以現在將 Ecto.Changeset 匯入 user.ex 模組,該模組也包含結構描述:

defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field(:name, :string)
  end
end

現在可以直接使用 cast/4 函數。

為結構描述提供一個或多個變更集建立函數是很平常的。現在建立一個接受結構體、更改的映射並回傳變更集的函數:

def changeset(struct, params) do
  struct
  |> cast(params, [:name])
end

現在可以確保 name 始終存在:

def changeset(struct, params) do
  struct
  |> cast(params, [:name])
  |> validate_required([:name])
end

當呼用 User.changeset/2 函數並傳遞一個空名稱時,變更集將不再有效,甚至會包含有用的錯誤消息。 註:在 iex 中工作時不要忘記執行 recompile() ,否則它將無法獲取你在程式碼中所做的更改。

iex> User.changeset(%User{}, %{"name" => ""})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [name: {"can't be blank", [validation: :required]}],
  data: #User<>,
  valid?: false
>

如果你嘗試使用上面變更集執行 Repo.insert(changeset),將收到 {:error, changeset} 回傳相同的錯誤,因此不必每次都檢查 changeset.valid?。 如果有的話,更容易嘗試執行插入、更新或刪除,且處理錯誤。

除了 validate_required/2 之外,還有 validate_length/3,它需要一些額外的選項:

def changeset(struct, params) do
  struct
  |> cast(params, [:name])
  |> validate_required([:name])
  |> validate_length(:name, min: 2)
end

如果傳遞一個由單個字元組成的名稱,可以嘗試猜測結果是什麼!

iex> User.changeset(%User{}, %{"name" => "A"})
#Ecto.Changeset<
  action: nil,
  changes: %{name: "A"},
  errors: [
    name: {"should be at least %{count} character(s)",
     [count: 2, validation: :length, min: 2]}
  ],
  data: #User<>,
  valid?: false
>

你可能會驚訝於錯誤訊息包含含義模糊的 %{count} - 這是為了幫助翻譯成其他語言;如果想直接向使用者顯示錯誤,可以使用 traverse_errors/2使它們成為人類可讀的 - 查看文件中提供的範例。

Ecto.Changeset 中其他的內建驗證器是:

  • validate_acceptance/3
  • validate_change/3 & /4
  • validate_confirmation/3
  • validate_exclusion/4 & validate_inclusion/4
  • validate_format/4
  • validate_number/3
  • validate_subset/4

可以在 這裡找到完整的清單,並詳細說明如何使用。

自訂驗證

雖然內建驗證器涵蓋了廣泛的使用案例,但你可能仍需要一些不同的。

到目前為止使用的每個 validate_ 函數都接受並回傳一個 %Ecto.Changeset{},因此可以輕鬆地插入自己的函數。

例如,可以確保只允許使用虛構的人物名稱:

@fictional_names ["Black Panther", "Wonder Woman", "Spiderman"]
def validate_fictional_name(changeset) do
  name = get_field(changeset, :name)

  if name in @fictional_names do
    changeset
  else
    add_error(changeset, :name, "is not a superhero")
  end
end

上面介紹了兩個新的輔助函數: get_field/3add_error/4。從函數名稱就幾乎無需解釋它們能做的事,但仍建議查看連結內文件。

總是回傳一個 %Ecto.Changeset{} 是好習慣,因為可以使用 |> 運算子,以便之後加入更多驗證:

def changeset(struct, params) do
  struct
  |> cast(params, [:name])
  |> validate_required([:name])
  |> validate_length(:name, min: 2)
  |> validate_fictional_name()
end
iex> User.changeset(%User{}, %{"name" => "Bob"})
#Ecto.Changeset<
  action: nil,
  changes: %{name: "Bob"},
  errors: [name: {"is not a superhero", []}],
  data: #User<>,
  valid?: false
>

好,它能動了!但是,實際上沒有必要自己實現這個函數 - 可以使用 validate_inclusion/4;仍然,你可以看到如何加入自己的 errors,這些應該是很有用的。

以程式方式加入變更

有時會希望手動對變更集匯入更改。為此目的存在 put_change/3 helper。

不要讓 name 欄位為必填,讓我們允許使用者在沒有名字的情況下註冊,並稱之為 “Anonymous”。 需要的函數看起來很熟悉 - 它接受並回傳一個變更集,就像之前介紹的 validate_fictional_name/1 一樣:

def set_name_if_anonymous(changeset) do
  name = get_field(changeset, :name)

  if is_nil(name) do
    put_change(changeset, :name, "Anonymous")
  else
    changeset
  end
end

只有使用者在應用程式中註冊時,才能將使用者名稱設定為 “Anonymous”;要做到這一點,將建立一個新的變更集建立函數:

def registration_changeset(struct, params) do
  struct
  |> cast(params, [:name])
  |> set_name_if_anonymous()
end

現在不必傳遞 nameAnonymous 會自動設定,就如預期的那樣:

iex> User.registration_changeset(%User{}, %{})
#Ecto.Changeset<
  action: nil,
  changes: %{name: "Anonymous"},
  errors: [],
  data: #User<>,
  valid?: true
>

具有特定職責的變更集建立函數 (如 registration_changeset/2) 並不罕見 - 有時需要靈活地僅執行某些驗證或篩選特定參數。 上面的函數可以在專用的 sign_up/1 helper 中其他地方使用:

def sign_up(params) do
  %User{}
  |> User.registration_changeset(params)
  |> Repo.insert()
end

結論

在本課程中有很多使用案例和功能並沒有涉及到,例如 無結構描述變更集 它可以用來驗證 任何 資料;或依著變更集(prepare_changes/2)處理副作用;或使用關聯(associations)和嵌入(embeds)。 可能會在未來的進階課程中介紹這些內容,但在此刻 - 我們鼓勵瀏覽 Ecto Changeset 文件 以獲得更多資訊。

Caught a mistake or want to contribute to the lesson? Edit this page on GitHub!