Poznaj Ecto, bezkompromisowe opakowanie baz danych dla współbieżnych aplikacji Elixir
Opublikowany: 2022-03-11Ecto to specyficzny dla domeny język do pisania zapytań i interakcji z bazami danych w języku Elixir. Najnowsza wersja (2.0) obsługuje PostgreSQL i MySQL. (wsparcie dla MSSQL, SQLite i MongoDB będzie dostępne w przyszłości). Jeśli jesteś nowy w Eliksirze lub masz z nim niewielkie doświadczenie, polecam przeczytanie Kleber Virgilio Correia's Getting Started with Elixir Programming Language.
Ecto składa się z czterech głównych komponentów:
- Ekto.Repo. Definiuje repozytoria, które są opakowaniami wokół magazynu danych. Za jego pomocą możemy wstawiać, tworzyć, usuwać i wyszukiwać repozytorium. Do komunikacji z bazą danych wymagany jest adapter i referencje.
- Schemat Ekto. Schematy służą do mapowania dowolnego źródła danych na strukturę Elixir.
- Ekto.Zestaw zmian. Zestawy zmian umożliwiają programistom filtrowanie i rzutowanie parametrów zewnętrznych, a także mechanizm śledzenia i weryfikowania zmian przed zastosowaniem ich do danych.
- Ekto.Zapytanie. Zapewnia zapytanie SQL podobne do DSL do pobierania informacji z repozytorium. Zapytania w Ecto są bezpieczne, co pozwala uniknąć typowych problemów, takich jak wstrzykiwanie SQL, a jednocześnie można je komponować, umożliwiając programistom budowanie zapytań kawałek po kawałku, a nie wszystkie naraz.
Do tego samouczka będziesz potrzebować:
- Zainstalowany Elixir (przewodnik instalacji w wersji 1.2 lub nowszej)
- Zainstalowano PostgreSQL
- Użytkownik zdefiniowany z uprawnieniami do tworzenia bazy danych (Uwaga: w tym samouczku użyjemy użytkownika „postgres” z hasłem „postgres” jako przykładu.)
Instalacja i konfiguracja
Na początek utwórzmy nową aplikację z przełożonym za pomocą Mix. Mix to narzędzie do kompilacji dostarczane z Elixirem, które zapewnia zadania do tworzenia, kompilowania, testowania aplikacji, zarządzania jej zależnościami i wiele więcej.
mix new cart --sup
Spowoduje to utworzenie koszyka katalogu z początkowymi plikami projektu:
* creating README.md * creating .gitignore * creating mix.exs * creating config * creating config/config.exs * creating lib * creating lib/ecto_tut.ex * creating test * creating test/test_helper.exs * creating test/ecto_tut_test.exs
Używamy opcji --sup
, ponieważ potrzebujemy drzewa nadzorcy, które utrzyma połączenie z bazą danych. Następnie przechodzimy do katalogu cart
z cd cart
i otwieramy plik mix.exs
i podmieniamy jego zawartość:
defmodule Cart.Mixfile do use Mix.Project def project do [app: :cart, version: "0.0.1", elixir: "~> 1.2", build_embedded: Mix.env == :prod, start_permanent: Mix.env == :prod, deps: deps] end def application do [applications: [:logger, :ecto, :postgrex], mod: {Cart, []}] end # Type "mix help deps" for more examples and options defp deps do [{:postgrex, ">= 0.11.1"}, {:ecto, "~> 2.0"}] end end
W def application do
musimy dodać jako aplikacje :postgrex, :ecto
aby można je było wykorzystać w naszej aplikacji. Musimy również dodać je jako zależności, dodając w defp deps do
postgrex (który jest adapterem bazy danych) i ecto . Po edycji pliku uruchom w konsoli:
mix deps.get
Spowoduje to zainstalowanie wszystkich zależności i utworzenie pliku mix.lock
, który przechowuje wszystkie zależności i pod-zależności zainstalowanych pakietów (podobnie jak Gemfile.lock
w bundlerze).
Ekto.Repo
Przyjrzymy się teraz, jak zdefiniować repozytorium w naszej aplikacji. Możemy mieć więcej niż jedno repozytorium, co oznacza, że możemy połączyć się z więcej niż jedną bazą danych. Musimy skonfigurować bazę danych w pliku config/config.exs
:
use Mix.Config config :cart, ecto_repos: [Cart.Repo]
Po prostu ustawiamy minimum, więc możemy uruchomić następne polecenie. Za pomocą linii :cart, cart_repos: [Cart.Repo]
mówimy Ecto, których repozytoriów używamy. Jest to fajna funkcja, ponieważ pozwala nam mieć wiele repozytoriów, tj. możemy łączyć się z wieloma bazami danych.
Teraz uruchom następujące polecenie:
mix ecto.gen.repo
==> connection Compiling 1 file (.ex) Generated connection app ==> poolboy (compile) Compiled src/poolboy_worker.erl Compiled src/poolboy_sup.erl Compiled src/poolboy.erl ==> decimal Compiling 1 file (.ex) Generated decimal app ==> db_connection Compiling 23 files (.ex) Generated db_connection app ==> postgrex Compiling 43 files (.ex) Generated postgrex app ==> ecto Compiling 68 files (.ex) Generated ecto app ==> cart * creating lib/cart * creating lib/cart/repo.ex * updating config/config.exs Don't forget to add your new repo to your supervision tree (typically in lib/cart.ex): supervisor(Cart.Repo, []) And to add it to the list of ecto repositories in your configuration files (so Ecto tasks work as expected): config :cart, ecto_repos: [Cart.Repo]
To polecenie generuje repozytorium. Jeśli przeczytasz dane wyjściowe, powie Ci, aby dodać nadzorcę i repozytorium w swojej aplikacji. Zacznijmy od przełożonego. lib/cart.ex
:
defmodule Cart do use Application def start(_type, _args) do import Supervisor.Spec, warn: false children = [ supervisor(Cart.Repo, []) ] opts = [strategy: :one_for_one, name: Cart.Supervisor] Supervisor.start_link(children, opts) end end
W tym pliku definiujemy nadzorcę supervisor(Cart.Repo, [])
i dodajemy go do listy dzieci (w Elixirze listy są podobne do tablic). Dzieci nadzorowane definiujemy strategią strategy: :one_for_one
, co oznacza, że jeśli jeden z nadzorowanych procesów zawiedzie, nadzorca zrestartuje tylko ten proces do stanu domyślnego. Możesz dowiedzieć się więcej o przełożonych tutaj. Jeśli spojrzysz na lib/cart/repo.ex
, zobaczysz, że ten plik został już utworzony, co oznacza, że mamy repozytorium dla naszej aplikacji.
defmodule Cart.Repo do use Ecto.Repo, otp_app: :cart end
Teraz edytujmy plik config/config.exs
:
use Mix.Config config :cart, ecto_repos: [Cart.Repo] config :cart, Cart.Repo, adapter: Ecto.Adapters.Postgres, database: "cart_dev", username: "postgres", password: "postgres", hostname: "localhost"
Po zdefiniowaniu całej konfiguracji dla naszej bazy danych możemy ją teraz wygenerować uruchamiając:
mix ecto.create
To polecenie tworzy bazę danych i tym samym w zasadzie zakończyliśmy konfigurację. Jesteśmy teraz gotowi do rozpoczęcia kodowania, ale najpierw zdefiniujmy zakres naszej aplikacji.
Tworzenie faktury za pomocą elementów wbudowanych
Dla naszej aplikacji demonstracyjnej zbudujemy proste narzędzie do fakturowania. W przypadku zestawów zmian (modeli) będziemy mieć Fakturę , Przedmiot i Fakturę . InvoiceItem należy do Invoice i Item . Ten diagram przedstawia, w jaki sposób nasze modele będą ze sobą powiązane:
Schemat jest dość prosty. Mamy fakturę tabeli, która ma wiele faktur_elementów , w której przechowujemy wszystkie szczegóły, a także elementy tabeli, która ma wiele faktur_elementów . Widać, że typem identyfikatora faktury i identyfikatora przedmiotu w tabeli faktura_elementy jest UUID. Używamy identyfikatora UUID, ponieważ pomaga on zaciemniać trasy na wypadek, gdybyś chciał ujawnić aplikację za pośrednictwem interfejsu API i ułatwia synchronizację, ponieważ nie jesteś zależny od numeru sekwencyjnego. Teraz stwórzmy tabele za pomocą zadań Mix.
Ekto.Migracja
Migracje to pliki używane do modyfikowania schematu bazy danych. Ecto.Migration udostępnia zestaw metod do tworzenia tabel, dodawania indeksów, tworzenia ograniczeń i innych rzeczy związanych ze schematem. Migracje naprawdę pomagają utrzymać synchronizację aplikacji z bazą danych. Stwórzmy skrypt migracji dla naszej pierwszej tabeli:
mix ecto.gen.migration create_invoices
Wygeneruje to plik podobny do priv/repo/migrations/20160614115844_create_invoices.exs
, w którym zdefiniujemy naszą migrację. Otwórz wygenerowany plik i zmodyfikuj jego zawartość tak, aby wyglądała następująco:
defmodule Cart.Repo.Migrations.CreateInvoices do use Ecto.Migration def change do create table(:invoices, primary_key: false) do add :id, :uuid, primary_key: true add :customer, :text add :amount, :decimal, precision: 12, scale: 2 add :balance, :decimal, precision: 12, scale: 2 add :date, :date timestamps end end end
W metodzie def change do
definiujemy schemat, który wygeneruje SQL dla bazy danych. create table(:invoices, primary_key: false) do
utworzy faktury tabeli . primary_key: false
ale dodamy pole ID typu UUID, pole klienta typu text, pole daty typu data. Metoda timestamps
wygeneruje pola inserted_at
i zaktualizowane_o, które updated_at
automatycznie wypełni odpowiednio z czasem wstawienia rekordu i czasem jego aktualizacji. Teraz przejdź do konsoli i uruchom migrację:
mix ecto.migrate
Stworzyliśmy invoice
tabelaryczną ze wszystkimi zdefiniowanymi polami. Stwórzmy tabelę przedmiotów :
mix ecto.gen.migration create_items
Teraz edytuj wygenerowany skrypt migracji:
defmodule Cart.Repo.Migrations.CreateItems do use Ecto.Migration def change do create table(:items, primary_key: false) do add :id, :uuid, primary_key: true add :name, :text add :price, :decimal, precision: 12, scale: 2 timestamps end end end
Nowością jest tutaj pole dziesiętne, które pozwala na liczby z 12 cyframi, z których 2 to dziesiętna część liczby. Przeprowadźmy migrację ponownie:
mix ecto.migrate
Teraz utworzyliśmy tabelę elementów i wreszcie stwórzmy tabelę faktura_elementy :
mix ecto.gen.migration create_invoice_items
Edytuj migrację:
defmodule Cart.Repo.Migrations.CreateInvoiceItems do use Ecto.Migration def change do create table(:invoice_items, primary_key: false) do add :id, :uuid, primary_key: true add :invoice_id, references(:invoices, type: :uuid, null: false) add :item_id, references(:items, type: :uuid, null: false) add :price, :decimal, precision: 12, scale: 2 add :quantity, :decimal, precision: 12, scale: 2 add :subtotal, :decimal, precision: 12, scale: 2 timestamps end create index(:invoice_items, [:invoice_id]) create index(:invoice_items, [:item_id]) end end
Jak widać, ta migracja ma kilka nowych części. Pierwszą rzeczą, którą zauważysz, jest add :invoice_id, references(:invoices, type: :uuid, null: false)
. Spowoduje to utworzenie pola id_faktury z ograniczeniem w bazie danych, które odwołuje się do tabeli faktur . Taki sam wzorzec mamy dla pola item_id . Inną rzeczą, która się różni, jest sposób, w jaki tworzymy indeks: create index(:invoice_items, [:invoice_id])
tworzy indeks faktura_items_invoice_id_index .
Ekto.Schemat i Ekto.Zestaw Zmian
W Ecto Ecto.Model
został przestarzały na rzecz używania Ecto.Schema
, więc będziemy nazywać schematy modułów zamiast modeli. Stwórzmy zestawy zmian. Zaczniemy od najprostszego elementu zestawu zmian i utworzymy plik lib/cart/item.ex
:
defmodule Cart.Item do use Ecto.Schema import Ecto.Changeset alias Cart.InvoiceItem @primary_key {:id, :binary_id, autogenerate: true} schema "items" do field :name, :string field :price, :decimal, precision: 12, scale: 2 has_many :invoice_items, InvoiceItem timestamps end @fields ~w(name price) def changeset(data, params \\ %{}) do data |> cast(params, @fields) |> validate_required([:name, :price]) |> validate_number(:price, greater_than_or_equal_to: Decimal.new(0)) end end
Na górze wstawiamy kod do zbioru zmian za pomocą use Ecto.Schema
. Używamy również import Ecto.Changeset
do importowania funkcjonalności z Ecto.Changeset . Mogliśmy określić, które konkretnie metody importować, ale niech to będzie proste. alias Cart.InvoiceItem
pozwala nam pisać bezpośrednio w zestawie zmian InvoiceItem , jak zobaczycie za chwilę.
Schemat ekto
@primary_key {:id, :binary_id, autogenerate: true}
określa, że nasz klucz podstawowy zostanie wygenerowany automatycznie. Ponieważ używamy typu UUID, definiujemy schemat za pomocą schema "items" do
i wewnątrz bloku definiujemy każde pole i relacje. Zdefiniowaliśmy nazwę jako ciąg, a cenę jako dziesiętną, bardzo podobnie do migracji. Następnie makro has_many :invoice_items, InvoiceItem
wskazuje relację między Item a InvoiceItem . Ponieważ zgodnie z konwencją nazwaliśmy pole item_id w tabeli conversion_items , nie musimy konfigurować klucza obcego. Wreszcie metoda znaczników czasu ustawi pola insert_at i updated_at .
Ekto.Zestaw zmian
Funkcja do def changeset(data, params \\ %{}) do
otrzymuje strukturę Elixir z parametrami, które prześlemy przez różne funkcje. cast(params, @fields)
wartości na właściwy typ. Na przykład możesz przekazać tylko ciągi w params, a te zostaną przekonwertowane na poprawny typ zdefiniowany w schemacie. validate_required([:name, :price])
sprawdza, czy występują pola nazwy i ceny , validate_number(:price, greater_than_or_equal_to: Decimal.new(0))
sprawdza, czy liczba jest większa lub równa 0 lub w tym przypadku Decimal.new(0)
.
To było dużo do ogarnięcia, więc spójrzmy na to w konsoli z przykładami, aby lepiej zrozumieć koncepcje:
iex -S mix
To załaduje konsolę. -S mix
ładuje bieżący projekt do iex REPL.
iex(0)> item = Cart.Item.changeset(%Cart.Item{}, %{name: "Paper", price: "2.5"}) #Ecto.Changeset<action: nil, changes: %{name: "Paper", price: #Decimal<2.5>}, errors: [], data: #Cart.Item<>, valid?: true>
Zwraca strukturę Ecto.Changeset
, która jest prawidłowa bez błędów. Teraz zapiszmy to:
iex(1)> item = Cart.Repo.insert!(item) %Cart.Item{__meta__: #Ecto.Schema.Metadata<:loaded, "items">, id: "66ab2ab7-966d-4b11-b359-019a422328d7", inserted_at: #Ecto.DateTime<2016-06-18 16:54:54>, invoice_items: #Ecto.Association.NotLoaded<association :invoice_items is not loaded>, name: "Paper", price: #Decimal<2.5>, updated_at: #Ecto.DateTime<2016-06-18 16:54:54>}
Nie pokazujemy SQL dla zwięzłości. W tym przypadku zwraca strukturę Cart.Item ze wszystkimi ustawionymi wartościami. Możesz zobaczyć, że insert_at i updated_at zawierają swoje znaczniki czasu, a pole id ma wartość UUID. Zobaczmy kilka innych przypadków:
iex(3)> item2 = Cart.Item.changeset(%Cart.Item{price: Decimal.new(20)}, %{name: "Scissors"}) #Ecto.Changeset<action: nil, changes: %{name: "Scissors"}, errors: [], data: #Cart.Item<>, valid?: true> iex(4)> Cart.Repo.insert(item2)
Teraz ustawiliśmy pozycję Scissors
w inny sposób, ustawiając cenę bezpośrednio %Cart.Item{price: Decimal.new(20)}
. Musimy ustawić jego prawidłowy typ, w przeciwieństwie do pierwszego elementu, w którym właśnie przekazaliśmy ciąg znaków jako cenę. Mogliśmy przekazać liczbę zmiennoprzecinkową, która zostałaby rzucona na typ dziesiętny. Jeśli przekażemy na przykład %Cart.Item{price: 12.5}
, po wstawieniu elementu wyrzuci wyjątek stwierdzający, że typ nie pasuje.

iex(4)> invalid_item = Cart.Item.changeset(%Cart.Item{}, %{name: "Scissors", price: -1.5}) #Ecto.Changeset<action: nil, changes: %{name: "Scissors", price: #Decimal<-1.5>}, errors: [price: {"must be greater than or equal to %{number}", [number: #Decimal<0>]}], data: #Cart.Item<>, valid?: false>
Aby zamknąć konsolę, naciśnij dwukrotnie Ctrl+C. Widać, że walidacje działają, a cena musi być większa lub równa zero (0). Jak widać, zdefiniowaliśmy cały schemat Ecto.Schema , który jest częścią związaną ze sposobem definiowania struktury modułu oraz zbiór zmian Ecto.Changeset , czyli wszystkie walidacje i rzutowanie. Kontynuujmy i stwórzmy plik lib/cart/invoice_item.ex
:
defmodule Cart.InvoiceItem do use Ecto.Schema import Ecto.Changeset @primary_key {:id, :binary_id, autogenerate: true} schema "invoice_items" do belongs_to :invoice, Cart.Invoice, type: :binary_id belongs_to :item, Cart.Item, type: :binary_id field :quantity, :decimal, precision: 12, scale: 2 field :price, :decimal, precision: 12, scale: 2 field :subtotal, :decimal, precision: 12, scale: 2 timestamps end @fields ~w(item_id price quantity) @zero Decimal.new(0) def changeset(data, params \\ %{}) do data |> cast(params, @fields) |> validate_required([:item_id, :price, :quantity]) |> validate_number(:price, greater_than_or_equal_to: @zero) |> validate_number(:quantity, greater_than_or_equal_to: @zero) |> foreign_key_constraint(:invoice_id, message: "Select a valid invoice") |> foreign_key_constraint(:item_id, message: "Select a valid item") |> set_subtotal end def set_subtotal(cs) do case {(cs.changes[:price] || cs.data.price), (cs.changes[:quantity] || cs.data.quantity)} do {_price, nil} -> cs {nil, _quantity} -> cs {price, quantity} -> put_change(cs, :subtotal, Decimal.mult(price, quantity)) end end end
Ten zestaw zmian jest większy, ale większość z nich powinieneś już znać. Tutaj belongs_to :invoice, Cart.Invoice, type: :binary_id
definiuje relację „należy do” ze zbiorem zmian Cart.Invoice , który wkrótce utworzymy. Następny belongs_to :item
tworzy relację z tabelą items. Zdefiniowaliśmy @zero Decimal.new(0)
. W tym przypadku @zero jest jak stała, do której można uzyskać dostęp wewnątrz modułu. Funkcja zestawu zmian ma nowe części, z których jedną jest foreign_key_constraint(:invoice_id, message: "Select a valid invoice")
. Umożliwi to wygenerowanie komunikatu o błędzie zamiast generowania wyjątku, gdy ograniczenie nie jest spełnione. I na koniec metoda set_subtotal obliczy sumę pośrednią. Przekazujemy zestaw zmian i zwracamy nowy zestaw zmian z obliczoną sumą częściową, jeśli mamy zarówno cenę, jak i ilość.
Teraz utwórzmy Cart.Invoice . Dlatego utwórz i edytuj plik lib/cart/invoice.ex
, aby zawierał następujące elementy:
defmodule Cart.Invoice do use Ecto.Schema import Ecto.Changeset alias Cart.{Invoice, InvoiceItem, Repo} @primary_key {:id, :binary_id, autogenerate: true} schema "invoices" do field :customer, :string field :amount, :decimal, precision: 12, scale: 2 field :balance, :decimal, precision: 12, scale: 2 field :date, Ecto.Date has_many :invoice_items, InvoiceItem, on_delete: :delete_all timestamps end @fields ~w(customer amount balance date) def changeset(data, params \\ %{}) do data |> cast(params, @fields) |> validate_required([:customer, :date]) end def create(params) do cs = changeset(%Invoice{}, params) |> validate_item_count(params) |> put_assoc(:invoice_items, get_items(params)) if cs.valid? do Repo.insert(cs) else cs end end defp get_items(params) do items = params[:invoice_items] || params["invoice_items"] Enum.map(items, fn(item)-> InvoiceItem.changeset(%InvoiceItem{}, item) end) end defp validate_item_count(cs, params) do items = params[:invoice_items] || params["invoice_items"] if Enum.count(items) <= 0 do add_error(cs, :invoice_items, "Invalid number of items") else cs end end end
Zestaw zmian Cart.Invoice ma pewne różnice. Pierwszy z nich znajduje się wewnątrz schematów : has_many :invoice_items, InvoiceItem, on_delete: :delete_all
oznacza, że gdy usuniemy fakturę, wszystkie powiązane z nią faktury zostaną usunięte. Pamiętaj jednak, że nie jest to ograniczenie zdefiniowane w bazie danych.
Wypróbujmy metodę tworzenia w konsoli, aby lepiej zrozumieć rzeczy. Być może stworzyłeś przedmioty („Papier”, „Nożyczki”), których będziemy tutaj używać:
iex(0)> item_ids = Enum.map(Cart.Repo.all(Cart.Item), fn(item)-> item.id end) iex(1)> {id1, id2} = {Enum.at(item_ids, 0), Enum.at(item_ids, 1) }
Pobraliśmy wszystkie przedmioty za pomocą Cart.Repo.all , a za pomocą funkcji Enum.map otrzymujemy po prostu item.id
każdego przedmiotu. W drugim wierszu po prostu przypisujemy id1
i id2
odpowiednio do pierwszego i drugiego identyfikatora item_id:
iex(2)> inv_items = [%{item_id: id1, price: 2.5, quantity: 2}, %{item_id: id2, price: 20, quantity: 1}] iex(3)> {:ok, inv} = Cart.Invoice.create(%{customer: "James Brown", date: Ecto.Date.utc, invoice_items: inv_items})
Faktura została utworzona z jej pozycjami fakturowymi i możemy teraz pobrać wszystkie faktury.
iex(4)> alias Cart.{Repo, Invoice} iex(5)> Repo.all(Invoice)
Możesz zobaczyć, że zwraca fakturę , ale chcielibyśmy również zobaczyć fakturę_elementy :
iex(6)> Repo.all(Invoice) |> Repo.preload(:invoice_items)
Dzięki funkcji Repo.preload możemy uzyskać invoice_items
. Zauważ, że może to przetwarzać zapytania jednocześnie. W moim przypadku zapytanie wyglądało tak:
iex(7)> Repo.get(Invoice, "5d573153-b3d6-46bc-a2c0-6681102dd3ab") |> Repo.preload(:invoice_items)
Ekto.Zapytanie
Do tej pory pokazaliśmy, jak tworzyć nowe pozycje i nowe faktury z relacjami. Ale co z zapytaniami? Cóż, pozwól, że przedstawię Ci Ecto.Query , które pomoże nam tworzyć zapytania do bazy danych, ale najpierw potrzebujemy więcej danych, aby lepiej wyjaśnić.
iex(1)> alias Cart.{Repo, Item, Invoice, InvoiceItem} iex(2)> Repo.insert(%Item{name: "Chocolates", price: Decimal.new("5")}) iex(3)> Repo.insert(%Item{name: "Gum", price: Decimal.new("2.5")}) iex(4)> Repo.insert(%Item{name: "Milk", price: Decimal.new("1.5")}) iex(5)> Repo.insert(%Item{name: "Rice", price: Decimal.new("2")}) iex(6)> Repo.insert(%Item{name: "Chocolates", price: Decimal.new("10")})
Powinniśmy teraz mieć 8 pozycji i powtarza się „Czekoladka”. Możemy chcieć wiedzieć, które pozycje się powtarzają. Wypróbujmy więc to zapytanie:
iex(7)> import Ecto.Query iex(8)> q = from(i in Item, select: %{name: i.name, count: (i.name)}, group_by: i.name) iex(9)> Repo.all(q) 19:12:15.739 [debug] QUERY OK db=2.7ms SELECT i0."name", count(i0."name") FROM "items" AS i0 GROUP BY i0."name" [] [%{count: 1, name: "Scissors"}, %{count: 1, name: "Gum"}, %{count: 2, name: "Chocolates"}, %{count: 1, name: "Paper"}, %{count: 1, name: "Milk"}, %{count: 1, name: "Test"}, %{count: 1, name: "Rice"}]
Widać, że w zapytaniu chcieliśmy zwrócić mapę z nazwą elementu i liczbą wystąpień w tabeli elementów. Ewentualnie możemy być bardziej zainteresowani sprawdzeniem, które produkty są najlepiej sprzedające się. Więc w tym celu stwórzmy kilka faktur. Po pierwsze, ułatwmy nam życie, tworząc mapę, aby uzyskać dostęp do item_id
:
iex(10)> l = Repo.all(from(i in Item, select: {i.name, i.id})) iex(11)> items = for {k, v} <- l, into: %{}, do: {k, v} %{"Chocolates" => "8fde33d3-6e09-4926-baff-369b6d92013c", "Gum" => "cb1c5a93-ecbf-4e4b-8588-cc40f7d12364", "Milk" => "7f9da795-4d57-4b46-9b57-a40cd09cf67f", "Paper" => "66ab2ab7-966d-4b11-b359-019a422328d7", "Rice" => "ff0b14d2-1918-495e-9817-f3b08b3fa4a4", "Scissors" => "397b0bb4-2b04-46df-84d6-d7b1360b6c72", "Test" => "9f832a81-f477-4912-be2f-eac0ec4f8e8f"}
Jak widać, stworzyliśmy mapę za pomocą rozumienia
iex(12)> line_items = [%{item_id: items["Chocolates"], quantity: 2}]
Aby utworzyć fakturę, musimy dodać cenę w parametrach invoice_items
, ale lepiej byłoby po prostu przekazać id przedmiotu i automatycznie uzupełnić cenę. Wprowadzimy zmiany w module Cart.Invoice , aby to osiągnąć:
defmodule Cart.Invoice do use Ecto.Schema import Ecto.Changeset import Ecto.Query # We add to query # .... # schema, changeset and create functions don't change # The new function here is items_with_prices defp get_items(params) do items = items_with_prices(params[:invoice_items] || params["invoice_items"]) Enum.map(items, fn(item)-> InvoiceItem.changeset(%InvoiceItem{}, item) end) end # new function to get item prices defp items_with_prices(items) do item_ids = Enum.map(items, fn(item) -> item[:item_id] || item["item_id"] end) q = from(i in Item, select: %{id: i.id, price: i.price}, where: i.id in ^item_ids) prices = Repo.all(q) Enum.map(items, fn(item) -> item_id = item[:item_id] || item["item_id"] %{ item_id: item_id, quantity: item[:quantity] || item["quantity"], price: Enum.find(prices, fn(p) -> p[:id] == item_id end)[:price] || 0 } end) end
Pierwszą rzeczą, którą zauważysz, jest to, że dodaliśmy Ecto.Query , co pozwoli nam na zapytanie bazy danych. Nową funkcją jest defp items_with_prices(items) do
która przeszukuje przedmioty i znajduje i ustawia cenę dla każdego elementu.
Po pierwsze, defp items_with_prices(items) do
otrzymuje listę jako argument. Z item_ids = Enum.map(items, fn(item) -> item[:item_id] || item["item_id"] end)
, iterujemy przez wszystkie elementy i otrzymujemy tylko item_id . Jak widać, uzyskujemy dostęp za pomocą atom :item_id
lub string „item_id”, ponieważ mapy mogą mieć jeden z tych kluczy jako klucze. Zapytanie q = from(i in Item, select: %{id: i.id, price: i.price}, where: i.id in ^item_ids)
znajdzie wszystkie elementy, które są w item_ids
i zwróci mapę z item.id
i item.price
. Następnie możemy uruchomić zapytanie prices = Repo.all(q)
, co zwróci listę map. Następnie musimy przejść przez pozycje i utworzyć nową listę, która doda cenę. Enum.map(items, fn(item) ->
iteruje przez każdy element, znajduje cenę Enum.find(prices, fn(p) -> p[:id] == item_id end)[:price] || 0
, i tworzy nową listę z item_id
, ilością i ceną. Dzięki temu nie ma już potrzeby dodawania ceny w każdej z invoice_items
.
Wstawianie większej liczby faktur
Jak pamiętacie, wcześniej stworzyliśmy mapę elementów , która umożliwia nam dostęp do identyfikatora za pomocą nazwy elementu np. items["Gum"]
"cb1c5a93-ecbf-4e4b-8588-cc40f7d12364". Ułatwia to tworzenie faktur_elementów . Stwórzmy więcej faktur. Uruchom konsolę ponownie i uruchom:
Iex -S mix
iex(1)> Repo.delete_all(InvoiceItem); Repo.delete_all(Invoice)
Usuwamy wszystkie faktury i faktury, aby mieć pustą kartę:
iex(2)> li = [%{item_id: items["Gum"], quantity: 2}, %{item_id: items["Milk"], quantity: 1}] iex(3)> Invoice.create(%{customer: "Mary Jane", date: Ecto.Date.utc, invoice_items: li}) iex(4)> li2 = [%{item_id: items["Chocolates"], quantity: 2}| li] iex(5)> Invoice.create(%{customer: "Mary Jane", date: Ecto.Date.utc, invoice_items: li2}) iex(5)> li3 = li2 ++ [%{item_id: items["Paper"], quantity: 3 }, %{item_id: items["Rice"], quantity: 1}, %{item_id: items["Scissors"], quantity: 1}] iex(6)> Invoice.create(%{customer: "Juan Perez", date: Ecto.Date.utc, invoice_items: li3})
Teraz mamy 3 faktury; pierwsza z 2 przedmiotami, druga z 3 przedmiotami, a trzecia z 6 przedmiotami. Chcielibyśmy teraz wiedzieć, które produkty najlepiej sprzedają się? Aby odpowiedzieć na to pytanie, utworzymy zapytanie, aby znaleźć najlepiej sprzedające się przedmioty według ilości i sumy częściowej (cena x ilość).
defmodule Cart.Item do use Ecto.Schema import Ecto.Changeset import Ecto.Query alias Cart.{InvoiceItem, Item, Repo} # schema and changeset don't change # ... def items_by_quantity, do: Repo.all items_by(:quantity) def items_by_subtotal, do: Repo.all items_by(:subtotal) defp items_by(type) do from i in Item, join: ii in InvoiceItem, on: ii.item_id == i.id, select: %{id: i.id, name: i.name, total: sum(field(ii, ^type))}, group_by: i.id, order_by: [desc: sum(field(ii, ^type))] end end
Importujemy Ecto.Query , a następnie nazywamy alias Cart.{InvoiceItem, Item, Repo}
, więc nie musimy dodawać koszyka na początku każdego modułu. Pierwsza funkcja items_by_quantity wywołuje funkcję items_by
, przekazując parametr :quantity
i wywołując Repo.all w celu wykonania zapytania. Funkcja items_by_subtotal jest podobna do poprzedniej funkcji, ale przekazuje parametr :subtotal
. Teraz wyjaśnijmy items_by :
-
from i in Item
, to makro wybiera moduł Item -
join: ii in InvoiceItem, on: ii.item_id == i.id
, tworzy sprzężenie na warunku „items.id = faktura_items.item_id” -
select: %{id: i.id, name: i.name, total: sum(field(ii, ^type))}
, generujemy mapę ze wszystkimi żądanymi polami najpierw wybieramy id i name z Item i robimy sumę operatora. Pole (ii, ^type) używa pola makra do dynamicznego dostępu do pola -
group_by: i.id
, Grupujemy według items.id -
order_by: [desc: sum(field(ii, ^type))]
i na koniec uporządkuj według sumy w porządku malejącym
Do tej pory pisaliśmy zapytanie w stylu listowym, ale mogliśmy przepisać je w stylu makr:
defp items_by(type) do Item |> join(:inner, [i], ii in InvoiceItem, ii.item_id == i.id) |> select([i, ii], %{id: i.id, name: i.name, total: sum(field(ii, ^type))}) |> group_by([i, _], i.id) |> order_by([_, ii], [desc: sum(field(ii, ^type))]) end
Wolę pisać zapytania w formie listy, ponieważ uważam ją za bardziej czytelną.
Wniosek
Omówiliśmy dużą część tego, co możesz zrobić w aplikacji z Ecto. Oczywiście z dokumentów Ecto można się dowiedzieć o wiele więcej. Dzięki Ecto możesz przy niewielkim wysiłku tworzyć współbieżne, odporne na błędy aplikacje, które można łatwo skalować dzięki maszynie wirtualnej Erlang. Ecto stanowi podstawę do przechowywania w aplikacjach Elixir oraz zapewnia funkcje i makra do łatwego zarządzania danymi.
W tym samouczku omówiliśmy Ecto.Schema, Ecto.Changeset, Ecto.Migration, Ecto.Query i Ecto.Repo. Każdy z tych modułów pomaga w różnych częściach aplikacji i sprawia, że kod jest bardziej przejrzysty oraz łatwiejszy w utrzymaniu i zrozumieniu.
Jeśli chcesz sprawdzić kod samouczka, możesz go znaleźć tutaj na GitHub.
Jeśli podobał Wam się ten poradnik i chcecie dowiedzieć się więcej, polecam Phoenix (lista świetnych projektów), Awesome Elixir i wykład porównujący ActiveRecord z Ecto.