Zestawy zmian

W celu wstawienia danych do bazy, ich zmiany lub usunięcia, funkcje Ecto.Repo.insert/2, update/2 i delete/2 wymagają zestawu zmian — changesetu — jako pierwszego parametru. Ale czym są changesety?

Niemal każdy programista zna problem sprawdzania danych wejściowych pod kątem potencjalnych błędów — chcemy mieć pewność, że dane są poprawne, zanim spróbujemy ich użyć do naszych celów.

Ecto dostarcza kompletne rozwiązanie do pracy ze zmianami danych — moduł i strukturę Changeset. W tej lekcji dowiemy się więcej na ten temat i nauczymy się, jak weryfikować integralność danych, zanim zapiszemy je do bazy.

Tworzenie zestawów zmian

Spójrzmy na pustą strukturę %Changeset{}:

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

Jak możesz zauważyć, ma ona kilka potencjalnie przydatnych pól, jednak wszystkie z nich są w tej chwili puste.

Aby changeset był naprawdę użyteczny, podczas jego tworzenia musimy przedstawić informację, jak wyglądają dane, które chcemy zmodyfikować. Cóż może być lepszym narzędziem do tego celu niż stworzone przez nas schematy, które definiują pola i ich typy?

Użyjmy naszego schematu Friends.Person z poprzedniej lekcji:

defmodule Friends.Person do
  use Ecto.Schema

  schema "people" do
    field :name, :string
    field :age, :integer, default: 0
  end
end

Aby utworzyć zestaw zmian dla schematu Person, użyjemy funkcji Ecto.Changeset.cast/3:

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

Pierwszym parametrem są oryginalne dane — w tym przypadku oryginalna struktura %Friends.Person{}. Ecto jest wystarczająco mądre, by znaleźć schemat jedynie na podstawie samej struktury. Drugie w kolejności są zmiany, których chcemy dokonać — w powyższym przypadku była to jedynie pusta mapa. Trzecim parametrem jest to, co czyni funkcję cast/3 wyjątkową: lista pól, które będą brane pod uwagę, co pozwala nam na kontrolowanie, które pola mogą być zmienione wraz z jednoczesną ochroną pozostałych z nich.

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

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

Możesz zauważyć, że nowe imię (pole name) zostało za drugim razem pominięte, gdyż jego zmiana nie była tam wprost dozwolona.

Alternatywą dla cast/3 jest funkcja change/2, która jednak nie pozwala na filtrowanie zmian w taki sposób, jaki umożliwia nam cast/3. Jest to użyteczne wtedy, gdy ufamy źródłu zmian, albo kiedy zmieniamy dane ręcznie.

Teraz możemy tworzyć zestawy zmian, jednak dopóki nie mamy żadnej walidacji, właściwie dowolne zmiany wartości pola name będą akceptowane, więc może się skończyć tak, że imię będzie pustą wartością:

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

Ecto mówi, że changeset jest poprawny, ale przecież nie chcemy dopuszczać pustych imion. Naprawmy to!

Walidacja

Ecto ma wbudowanych wiele niezwykle pomocnych funkcji do walidacji danych.

Będziemy używać Ecto.Changeset w wielu miejscach, więc zaimportujmy go w module zdefiniowanym w person.ex, zawierającym również nasz schemat:

defmodule Friends.Person do
  use Ecto.Schema
  import Ecto.Changeset

  schema "people" do
    field :name, :string
    field :age, :integer, default: 0
  end
end

Teraz możemy używać funkcji cast/3 bezpośrednio.

Powszechnym jest definiowanie jednej lub kilku funkcji tworzących zestawy zmian dla schematu. Stwórzmy zatem taką — jej parametrami będą struktura i mapa ze zmianami, a zwracany będzie changeset:

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

Teraz możemy rozszerzyć tę funkcję, by zagwarantować, że imię będzie zawsze obecne:

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

Gdy wywołujemy funkcję Friends.Person.changeset/2 i przekazujemy pustą wartość jako name, zestaw zmian nie będzie już poprawny, a na dodatek będzie zawierał pomocny komunikat błędu. Uwaga: jeśli pracujesz w konsoli iex, nie zapomnij uruchomić funkcji recompile() po wprowadzeniu zmian w kodzie, w przeciwnym razie nie będą one załadowane.

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

Jeśli spróbujesz wywołać Repo.insert(changeset) ze stworzonym wyżej changesetem, funkcja zwróci krotkę {:error, changeset} z tym samym błędem, więc nie musisz za każdym razem sprawdzać wartości changeset.valid?. Łatwiej jest spróbować wstawić, zmodyfikować lub usunąć rekord, a następnie obsłużyć błąd, jeśli taki się pojawi.

Oprócz validate_required/2, mamy również do dyspozycji funkcję validate_length/3, która przyjmuje kilka dodatkowych opcji:

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

Możesz spróbować zgadnąć, jaki będzie wynik, jeśli przekażemy imię składające się z tylko jednej litery!

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

Może być dla Ciebie zaskakujące, że komunikat błędu zawiera %{count} — jest to pomocne w tłumaczeniu na inne języki; jeśli chcesz pokazywać bezpośrednio błędy użytkownikowi, możesz je uczynić czytelnymi dla ludzi za pomocą funkcji traverse_errors/2 — zapoznaj się z przykładem w dokumentacji.

Niektóre spośród pozostałych walidatorów wbudowanych w Ecto.Changeset to:

Pełną listę ze szczegółową instrukcją ich użycia możesz znaleźć tutaj.

Własne walidacje

Choć wbudowane walidatory pozwalają obsłużyć szeroką gamę przypadków użycia, i tak możesz potrzebować czegoś innego.

Każda z funkcji validate_, których dotąd używaliśmy, przyjmuje i zwraca %Ecto.Changeset{}, więc możemy łatwo podłączyć własny walidator.

Możemy na przykład akceptować jedynie imiona superbohaterów:

@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

Powyżej wprowadziliśmy dwie nowe funkcje pomocnicze: get_field/3 i add_error/4. Nazwy raczej dobrze opisują ich działanie, ale i tak zachęcam do zajrzenia do dokumentacji.

Dobrą praktyką jest, by w funkcjach tego rodzaju zwracać zawsze %Ecto.Changeset{}, dzięki czemu można potem użyć operatora |> i ułatwić późniejsze dodawanie kolejnych walidacji:

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

Świetnie, to działa! Tak naprawdę w tym przypadku nie musieliśmy implementować własnej funkcji — zamiast tego mogliśmy użyć validate_inclusion/4 — jednakże przykład ten pokazał, jak możemy dodawać własne błędy do changesetów, co powinno okazać się przydatne.

Programowe dodawanie zmian

Czasem możesz chcieć wprowadzić ręcznie jakieś zmiany do changesetu. Funkcja pomocnicza put_change/3 istnieje właśnie w tym celu.

Zamiast wymagać niepustej wartości pola name, pozwólmy użytkownikom rejestrować się bez podawania imienia i nazywajmy ich wtedy “Anonymous”. Funkcja, której potrzebujemy, może wyglądać znajomo — przyjmuje i zwraza zestaw zmian, tak jak validate_fictional_name/1, którą stworzyliśmy wcześniej:

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

Możemy chcieć przypisywać użytkownikom “Anonymous” jako imię jedynie w momencie, w którym rejestrują się w naszej aplikacji — aby to uczynić, stwórzmy nową funkcję tworzącą changeset:

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

Teraz nie jest konieczne podawanie imienia, a w razie jego braku wartość Anonymous będzie ustawiana automatycznie, tak jak tego oczekiwaliśmy:

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

Oddzielne funkcje tworzące changesety dla różnych przypadków użycia (takie jak registration_changeset/2) nie są rzeczą rzadką — czasem potrzebna jest pewna elastyczność, by wykonywać jedynie określone walidacje czy filtrować konkretne parametry. Wymieniona wyżej funkcja może być użyta gdzieś indziej w funkcji sign_up/1:

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

Podsumowanie

Istnieje wiele przypadków użycia, o których nie powiedzieliśmy w tej lekcji, takich jak zestawy zmian bez schematów, których możesz użyć do walidacji dowolnych danych, czy też obsługa efektów ubocznych w changesetach (prepare_changes/2), praca z asocjacjami i strukturami wbudowanymi. Możemy się tym zająć w przyszłości, w lekcji na poziomie zaawansowanym, a w międzyczasie zachęcamy do zapoznania się z dokumentacją Ecto Changeset, gdzie można znaleźć więcej informacji na ten temat.

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