Ecto i Changeset - jak Elixir eliminuje błędy w danych, zanim trafią do bazy
Piątek, 16:47. Handlowiec wpisuje zamówienie: ilość 1 000, cena jednostkowa 0,00 PLN. System przyjmuje. W poniedziałek księgowość odkrywa fakturę na 0 złotych, towar już wysłany. Klient „zapomina" zapłacić. Strata: 47 000 PLN.
Jak to możliwe? Bo system walidował tylko „czy pole nie jest puste". Nie sprawdzał, czy dane mają sens biznesowy.
W Elixirze ten problem rozwiązuje Ecto Changeset - mechanizm, który traktuje walidację danych jako pierwszorzędny element architektury, nie jako dodatek.
Czym jest Changeset
Changeset to opis zmiany, zanim zostanie zapisana. Nie modyfikujesz danych bezpośrednio - tworzysz obiekt opisujący, co chcesz zmienić, a system decyduje, czy ta zmiana jest dozwolona.
# Changeset to NIE jest zapis do bazy
# To propozycja zmiany, którą można zaakceptować lub odrzucić
changeset = Order.changeset(%Order{}, %{
customer_id: 42,
quantity: 1000,
unit_price: 0.00
})
# changeset.valid? => false
# changeset.errors => [unit_price: {"musi być większa od 0", []}]
# Dane NIGDY nie trafią do bazy w niepoprawnym staniePorównajmy to z typowym podejściem:
// Node.js / Express - typowe podejście
app.post('/orders', async (req, res) => {
// Walidacja? Może jest, może nie...
// Każdy developer robi to inaczej
if (!req.body.customer_id) {
return res.status(400).json({ error: 'Brak klienta' });
}
// A co z ceną 0? A ujemną ilością?
// A zamówieniem dla zablokowanego klienta?
// Kto o tym pamięta?
const order = await db.orders.create(req.body);
res.json(order);
});// Java / Spring - walidacja przez annotacje
public class CreateOrderRequest {
@NotNull
private Long customerId;
@Min(1)
private Integer quantity;
@DecimalMin("0.01")
private BigDecimal unitPrice;
// Proste reguły? Tak.
// "Cena nie może być niższa niż koszt zakupu"? Annotacja tego nie wyrazi.
// "Klient nie może mieć więcej niż 5 niezapłaconych zamówień"? Też nie.
}Ecto Changeset łączy prostą walidację pól z dowolnie złożoną logiką biznesową w jednym, spójnym mechanizmie.
Anatomia Changeseta
Schema - definicja struktury danych
defmodule MyApp.Orders.Order do
use Ecto.Schema
schema "orders" do
field :number, :string
field :quantity, :integer
field :unit_price, :decimal
field :total_price, :decimal
field :status, Ecto.Enum, values: [:draft, :confirmed, :shipped, :completed, :cancelled]
field :notes, :string
field :shipping_date, :date
belongs_to :customer, MyApp.Customers.Customer
has_many :items, MyApp.Orders.Item
timestamps()
end
endTo nie jest ORM w tradycyjnym sensie. Ecto Schema to mapowanie danych, nie obiekt z zachowaniami. Dane i logika są rozdzielone - changeset to logika, schema to struktura.
Changeset - pipeline walidacji
def changeset(order, attrs) do
order
|> cast(attrs, [:customer_id, :quantity, :unit_price, :notes, :shipping_date])
|> validate_required([:customer_id, :quantity, :unit_price])
|> validate_number(:quantity, greater_than: 0, less_than: 100_000)
|> validate_number(:unit_price, greater_than: Decimal.new("0"))
|> validate_length(:notes, max: 1000)
|> foreign_key_constraint(:customer_id)
|> generate_order_number()
|> calculate_total_price()
endKażda linia to jedno przekształcenie. Pipeline czyta się od góry do dołu jak listę reguł biznesowych. Jeśli którakolwiek walidacja się nie powiedzie, changeset jest oznaczony jako valid?: false i żadna operacja bazodanowa nie zostanie wykonana.
cast - pierwsza linia obrony
cast/3 to strażnik, który decyduje, które pola w ogóle mogą być zmienione:
# Tylko te pola mogą przyjść z formularza / API
cast(attrs, [:customer_id, :quantity, :unit_price, :notes, :shipping_date])
# Co się stanie, jeśli ktoś wyśle:
# %{"quantity" => 10, "status" => "completed", "total_price" => 0}
#
# cast przepuści TYLKO :quantity
# :status i :total_price zostaną zignorowane
# Bo nie ma ich na liście dozwolonych pólTo jest mass assignment protection wbudowany w język. W Rails był z tym słynny problem (GitHub hack 2012). W Phoenix/Ecto to domyślne zachowanie - musisz jawnie zadeklarować, co wolno zmieniać.
Różne changesety dla różnych operacji
defmodule MyApp.Orders.Order do
# Tworzenie - handlowiec wypełnia formularz
def create_changeset(order, attrs) do
order
|> cast(attrs, [:customer_id, :quantity, :unit_price, :notes])
|> validate_required([:customer_id, :quantity, :unit_price])
|> validate_number(:quantity, greater_than: 0)
|> validate_number(:unit_price, greater_than: Decimal.new("0"))
|> put_change(:status, :draft)
|> generate_order_number()
|> calculate_total_price()
end
# Potwierdzenie - kierownik zatwierdza
def confirm_changeset(order) do
order
|> change()
|> validate_status_transition(:draft, :confirmed)
|> validate_customer_credit_limit()
|> put_change(:status, :confirmed)
end
# Wysyłka - magazynier oznacza jako wysłane
def ship_changeset(order, attrs) do
order
|> cast(attrs, [:shipping_date])
|> validate_required([:shipping_date])
|> validate_status_transition(:confirmed, :shipped)
|> validate_shipping_date_not_in_past()
|> put_change(:status, :shipped)
end
# Anulowanie - ale tylko jeśli nie wysłano
def cancel_changeset(order, attrs) do
order
|> cast(attrs, [:cancellation_reason])
|> validate_required([:cancellation_reason])
|> validate_inclusion(:status, [:draft, :confirmed])
|> put_change(:status, :cancelled)
end
endKażda operacja biznesowa ma własny changeset. Handlowiec nie może zmienić statusu na „wysłane". Magazynier nie może zmienić ceny. Nikt nie może anulować wysłanego zamówienia. Reguły są w kodzie, nie w głowie programisty.
Walidacje wbudowane
Ecto daje zestaw walidacji pokrywający 80% potrzeb:
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :sku, :price, :weight, :category, :email, :url])
# Wymagane pola
|> validate_required([:name, :sku, :price])
# Długość tekstu
|> validate_length(:name, min: 3, max: 200)
|> validate_length(:sku, is: 10) # dokładnie 10 znaków
# Wartości numeryczne
|> validate_number(:price, greater_than: 0, less_than: 1_000_000)
|> validate_number(:weight, greater_than_or_equal_to: 0)
# Lista dozwolonych wartości
|> validate_inclusion(:category, ["electronics", "furniture", "food"])
# Lista zabronionych wartości
|> validate_exclusion(:name, ["test", "TODO", "asdf"])
# Format (regex)
|> validate_format(:sku, ~r/^[A-Z]{3}-\d{7}$/)
|> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
# Akceptacja (checkboxy regulaminów)
|> validate_acceptance(:terms_of_service)
# Unikalność (sprawdzana przy zapisie do bazy)
|> unique_constraint(:sku)
|> unique_constraint([:name, :category], name: :products_name_category_index)
# Klucze obce
|> foreign_key_constraint(:category_id)
# Ograniczenia CHECK z PostgreSQL
|> check_constraint(:price, name: :price_must_be_positive)
endWalidacje niestandardowe - logika biznesowa
Prawdziwa moc changesetów to walidacje, których nie wyrazi żadna annotacja ani decorator:
Walidacja limitu kredytowego klienta
defp validate_customer_credit_limit(changeset) do
validate_change(changeset, :customer_id, fn :customer_id, customer_id ->
customer = Customers.get_customer!(customer_id)
unpaid = Orders.total_unpaid_for_customer(customer_id)
order_value = get_field(changeset, :total_price)
cond do
customer.blocked ->
[customer_id: "klient jest zablokowany"]
Decimal.add(unpaid, order_value) |> Decimal.gt?(customer.credit_limit) ->
[customer_id: "przekroczony limit kredytowy (#{customer.credit_limit} PLN)"]
true ->
[]
end
end)
endWalidacja przejść statusów
# Dozwolone przejścia statusów - automat stanowy
@transitions %{
draft: [:confirmed, :cancelled],
confirmed: [:shipped, :cancelled],
shipped: [:completed],
completed: [],
cancelled: []
}
defp validate_status_transition(changeset, from, to) do
allowed = Map.get(@transitions, from, [])
if to in allowed do
changeset
else
add_error(changeset, :status,
"nie można zmienić statusu z #{from} na #{to}")
end
end
# draft → confirmed ✓
# draft → shipped ✗ ("nie można zmienić statusu z draft na shipped")
# completed → draft ✗ ("nie można zmienić statusu z completed na draft")Walidacja spójności dat
defp validate_shipping_date_not_in_past(changeset) do
validate_change(changeset, :shipping_date, fn :shipping_date, date ->
if Date.compare(date, Date.utc_today()) == :lt do
[shipping_date: "data wysyłki nie może być w przeszłości"]
else
[]
end
end)
end
defp validate_date_range(changeset) do
start_date = get_field(changeset, :start_date)
end_date = get_field(changeset, :end_date)
if start_date && end_date && Date.compare(start_date, end_date) == :gt do
add_error(changeset, :end_date, "musi być późniejsza niż data początkowa")
else
changeset
end
endWalidacja zależna od innych pól
def changeset(invoice, attrs) do
invoice
|> cast(attrs, [:type, :vat_rate, :vat_id, :amount])
|> validate_required([:type, :amount])
|> validate_vat_fields()
end
defp validate_vat_fields(changeset) do
case get_field(changeset, :type) do
:domestic ->
changeset
|> validate_required([:vat_rate])
|> validate_inclusion(:vat_rate, [23, 8, 5, 0])
:eu_export ->
changeset
|> validate_required([:vat_id])
|> validate_format(:vat_id, ~r/^[A-Z]{2}\d{8,12}$/)
|> put_change(:vat_rate, 0)
:non_eu_export ->
changeset
|> put_change(:vat_rate, 0)
_ ->
add_error(changeset, :type, "nieznany typ faktury")
end
endEcto.Multi - transakcje jako pipeline
Changeset waliduje jedną encję. Ale operacje biznesowe często dotyczą wielu tabel jednocześnie. Do tego służy Ecto.Multi:
def confirm_order(order) do
Ecto.Multi.new()
|> Ecto.Multi.update(:order, Order.confirm_changeset(order))
|> Ecto.Multi.run(:stock, fn _repo, %{order: order} ->
reduce_stock(order)
end)
|> Ecto.Multi.insert(:event, fn %{order: order} ->
OrderEvent.changeset(%OrderEvent{}, %{
order_id: order.id,
type: :confirmed,
performed_by: order.confirmed_by
})
end)
|> Ecto.Multi.run(:notification, fn _repo, %{order: order} ->
send_confirmation_email(order)
end)
|> Repo.transaction()
end
# Co się dzieje:
# 1. Zmień status zamówienia → jeśli changeset invalid → STOP
# 2. Zmniejsz stan magazynowy → jeśli brak towaru → ROLLBACK (1)
# 3. Zapisz event w historii → jeśli błąd → ROLLBACK (1, 2)
# 4. Wyślij email → jeśli błąd → ROLLBACK (1, 2, 3)
#
# Albo WSZYSTKO się udaje, albo NIC się nie zmienia.
# Baza nigdy nie jest w niespójnym stanie.Porównajmy z typowym kodem bez Multi:
// Node.js - ręczne zarządzanie transakcją
async function confirmOrder(orderId) {
const trx = await db.transaction();
try {
await trx('orders').where({ id: orderId }).update({ status: 'confirmed' });
await reduceStock(trx, orderId); // A co jeśli to rzuci wyjątek?
await trx('order_events').insert({ order_id: orderId, type: 'confirmed' });
await sendEmail(orderId); // A co jeśli email padnie?
await trx.commit();
} catch (err) {
await trx.rollback(); // Czy na pewno pamiętamy o rollback?
throw err; // A co jeśli rollback padnie?
}
}
// Kto waliduje status transition? Kto sprawdza credit limit?
// Kto pilnuje, żeby walidacja była taka sama w API i w UI?
// Odpowiedź: nikt - chyba że ktoś o tym pamięta.Obsługa błędów w formularzu
Changeset integruje się z Phoenix LiveView tak, że błędy wyświetlają się automatycznie przy właściwych polach:
# LiveView - formularz z walidacją w czasie rzeczywistym
defmodule MyAppWeb.OrderLive.FormComponent do
use MyAppWeb, :live_component
def update(%{order: order} = assigns, socket) do
changeset = Orders.change_order(order)
{:ok, assign(socket, assigns) |> assign_form(changeset)}
end
# Walidacja uruchamia się przy KAŻDEJ zmianie pola
def handle_event("validate", %{"order" => params}, socket) do
changeset =
socket.assigns.order
|> Orders.change_order(params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
# Zapis uruchamia pełną walidację + zapis do bazy
def handle_event("save", %{"order" => params}, socket) do
case Orders.create_order(params) do
{:ok, order} ->
{:noreply,
socket
|> put_flash(:info, "Zamówienie #{order.number} utworzone")
|> push_navigate(to: ~p"/orders/#{order}")}
{:error, changeset} ->
# Błędy automatycznie wyświetlą się przy polach
{:noreply, assign_form(socket, changeset)}
end
end
end<!-- Template - błędy wyświetlają się automatycznie -->
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:customer_id]} label="Klient" type="select" options={@customers} />
<.input field={@form[:quantity]} label="Ilość" type="number" />
<.input field={@form[:unit_price]} label="Cena jednostkowa" type="number" step="0.01" />
<.input field={@form[:notes]} label="Uwagi" type="textarea" />
<!-- Jeśli quantity = 0, użytkownik widzi "musi być większa od 0"
Jeśli unit_price puste, widzi "pole wymagane"
Jeśli przekroczony credit limit, widzi komunikat
Wszystko BEZ przeładowania strony, BEZ JavaScriptu -->
<.button>Utwórz zamówienie</.button>
</.form>Walidacja działa identycznie w formularzu i w API. Nie ma możliwości, żeby API przyjęło dane, które formularz odrzuciłby. Jeden changeset, jedno źródło prawdy.
Changeset vs inne podejścia
| Cecha | Ecto Changeset | Java Bean Validation | Express + Joi | Django Forms |
|---|---|---|---|---|
| Walidacja na poziomie pola | ✓ | ✓ | ✓ | ✓ |
| Walidacja cross-field | ✓ (natywnie) | Custom validator (klasa) | Custom (funkcja) | clean() (metoda) |
| Walidacja z zapytaniem do bazy | ✓ (validate_change) | Wymaga serwisu | Wymaga middleware | Wymaga override |
| Mass assignment protection | Wbudowany (cast) | Ręczny (DTO) | Ręczny | Ręczny (fields) |
| Różne reguły per operacja | Osobne changesety | Grupy walidacyjne | Osobne schemas | Osobne formy |
| Pipeline/kompozycja | Natywny (|>) | Brak | Brak | Brak |
| Integracja z formularzem | Automatyczna (LiveView) | Ręczna | Ręczna | Automatyczna |
| Integracja z transakcjami | Ecto.Multi | @Transactional | Ręczna | atomic() |
| Komunikaty błędów i18n | Wbudowane (Gettext) | messages.properties | Ręczne | Wbudowane |
Wzorce, które stosujemy w produkcji
Wzorzec 1: Walidacja na dwóch poziomach
defmodule MyApp.Orders.Order do
# Poziom 1: Changeset - reguły aplikacyjne
def changeset(order, attrs) do
order
|> cast(attrs, [:quantity, :unit_price, :customer_id])
|> validate_required([:quantity, :unit_price, :customer_id])
|> validate_number(:quantity, greater_than: 0)
|> validate_number(:unit_price, greater_than: Decimal.new("0"))
|> foreign_key_constraint(:customer_id)
|> check_constraint(:total_price, name: :total_must_be_positive)
end
end-- Poziom 2: PostgreSQL - reguły bazodanowe (ostatnia linia obrony)
ALTER TABLE orders
ADD CONSTRAINT total_must_be_positive CHECK (total_price > 0);
ALTER TABLE orders
ADD CONSTRAINT quantity_must_be_positive CHECK (quantity > 0);
-- Nawet jeśli bug w kodzie ominie changeset,
-- baza danych ODRZUCI niepoprawne daneDwie warstwy obrony. Changeset łapie 99% problemów z czytelnymi komunikatami. PostgreSQL CHECK łapie resztę - na wypadek, gdyby ktoś wstawił dane z konsoli lub z innego systemu.
Wzorzec 2: Changeset z kontekstem biznesowym
defmodule MyApp.Orders do
# Kontekst biznesowy decyduje o regułach
def create_order(attrs, %User{role: :sales}) do
%Order{}
|> Order.create_changeset(attrs)
|> validate_sales_limits(attrs)
|> Repo.insert()
end
def create_order(attrs, %User{role: :admin}) do
%Order{}
|> Order.admin_changeset(attrs) # Admin może więcej
|> Repo.insert()
end
defp validate_sales_limits(changeset, _attrs) do
quantity = get_field(changeset, :quantity)
unit_price = get_field(changeset, :unit_price)
changeset
|> validate_number(:quantity, less_than_or_equal_to: 10_000)
|> validate_discount_limit(unit_price)
end
end
# Handlowiec: max 10 000 szt., max 15% rabatu
# Admin: bez limitów
# Ta sama baza, te same tabele, różne regułyWzorzec 3: Audit trail z changesetem
def update_order(order, attrs, current_user) do
changeset = Order.update_changeset(order, attrs)
Ecto.Multi.new()
|> Ecto.Multi.update(:order, changeset)
|> Ecto.Multi.insert(:audit, fn %{order: updated_order} ->
AuditLog.changeset(%AuditLog{}, %{
entity: "orders",
entity_id: order.id,
action: "update",
user_id: current_user.id,
changes: changeset.changes |> Map.delete(:updated_at) |> Jason.encode!(),
previous_values: extract_previous(order, changeset.changes)
})
end)
|> Repo.transaction()
end
# Każda zmiana jest logowana z:
# - Kto zmienił
# - Co zmienił (dokładne pola)
# - Jaka była poprzednia wartość
# - Kiedy zmienił
# Changeset.changes daje DOKŁADNĄ listę zmienionych pólWzorzec 4: Walidacja zależna od zewnętrznego systemu
defp validate_vat_number(changeset) do
validate_change(changeset, :vat_id, fn :vat_id, vat_id ->
case VIESClient.verify(vat_id) do
{:ok, %{valid: true}} ->
[]
{:ok, %{valid: false}} ->
[vat_id: "numer VAT nie jest aktywny w systemie VIES"]
{:error, :timeout} ->
# VIES nie odpowiada - przepuszczamy, zweryfikujemy później
Logger.warning("VIES timeout for #{vat_id}")
[]
end
end)
end
# Walidacja NIP przez VIES:
# - Jeśli numer jest aktywny → przepuść
# - Jeśli nieaktywny → odrzuć z komunikatem
# - Jeśli serwis nie odpowiada → przepuść, zaloguj, sprawdź późniejSchemat ochrony danych
Dane od użytkownika (formularz / API / import)
│
▼
┌─────────────────────────┐
│ cast() │ ← Odrzuć nieznane pola
│ Przepuść TYLKO dozwolone│ (mass assignment protection)
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ validate_required() │ ← Wymagane pola
│ validate_format() │ ← Poprawny format
│ validate_number() │ ← Zakresy wartości
│ validate_length() │ ← Limity długości
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ Walidacje biznesowe │ ← Credit limit, status transition,
│ validate_change() │ cross-field, zależności
│ add_error() │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ Ecto.Multi │ ← Transakcja: wszystko albo nic
│ (opcjonalnie) │ Wiele tabel, spójność
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ PostgreSQL │ ← Ostatnia linia obrony
│ CHECK, UNIQUE, FK │ Constraints bazodanowe
│ NOT NULL │
└───────────┬─────────────┘
│
▼
✅ Dane w bazie
(poprawne, spójne, audytowalne)Ile błędów łapie changeset
Na podstawie naszych projektów - statystyki z systemu e-commerce (6 miesięcy, 340 000 operacji):
| Warstwa | Błędy złapane | Przykłady |
|---|---|---|
| cast (nieznane pola) | 12 400 | Próby zmiany statusu, ceny po rabacie, pól systemowych |
| validate_required | 8 200 | Puste formularze, brakujące pola w API |
| validate_number / format | 4 100 | Ujemne ilości, niepoprawne NIP-y, za długie teksty |
| Walidacje biznesowe | 2 800 | Przekroczony credit limit, niedozwolona zmiana statusu |
| PostgreSQL constraints | 340 | Race conditions (unique), usunięty klient (FK) |
| Łącznie odrzuconych | 27 840 | 8.2% wszystkich operacji |
Prawie co dziesiąta operacja zawierała dane, które nie powinny trafić do bazy. Bez changesetów te dane byłyby zapisane - i powodowałyby problemy tygodniami, zanim ktoś by je zauważył.
Co to oznacza dla Twojego biznesu
Bez systemu walidacji:
- Handlowiec wpisze cenę 0 PLN → faktura na 0 zł
- Import z Excela doda duplikaty → zawyżone stany magazynowe
- API partnera wyśle ujemną ilość → magazyn „przybywa" z nikąd
- Ktoś zmieni status zamówienia na „wysłane" bez wysyłki → chaos w logistyce
- Odkrycie błędnych danych → tygodnie lub miesiące po fakcie
Z Ecto Changeset:
- Błędne dane są odrzucane natychmiast z czytelnym komunikatem
- Każda operacja ma własne reguły - handlowiec nie obejdzie limitu
- Transakcje gwarantują spójność między tabelami
- PostgreSQL jako ostatnia linia obrony łapie race conditions
- Audit trail loguje kto, co i kiedy zmienił
To nie jest „feature Elixira". To architektura systemu, w którym niepoprawne dane nie mają prawa istnieć.
Chcesz system, w którym dane są zawsze poprawne, a błędy łapane zanim cokolwiek zapiszą? Porozmawiajmy - pokażemy, jak Ecto Changeset chroni dane w prawdziwych systemach biznesowych.