พบกับ Ecto ฐานข้อมูล Wrapper ที่ไม่มีการประนีประนอมสำหรับแอพ Elixir พร้อมกัน
เผยแพร่แล้ว: 2022-03-11Ecto เป็นภาษาเฉพาะโดเมนสำหรับเขียนข้อความค้นหาและโต้ตอบกับฐานข้อมูลในภาษา Elixir เวอร์ชันล่าสุด (2.0) รองรับ PostgreSQL และ MySQL (รองรับ MSSQL, SQLite และ MongoDB ในอนาคต) ในกรณีที่คุณยังใหม่ต่อ Elixir หรือมีประสบการณ์เพียงเล็กน้อย เราขอแนะนำให้คุณอ่าน Kleber Virgilio Correia's Getting Started with Elixir Programming Language
Ecto ประกอบด้วยสี่องค์ประกอบหลัก:
- Ecto.Repo. กำหนดที่เก็บที่ห่อหุ้มรอบที่เก็บข้อมูล เมื่อใช้สิ่งนี้ เราสามารถแทรก สร้าง ลบ และสอบถาม repo ต้องใช้อะแดปเตอร์และข้อมูลประจำตัวในการสื่อสารกับฐานข้อมูล
- Ecto.สคีมา. สคีมาใช้เพื่อแมปแหล่งข้อมูลใดๆ ลงในโครงสร้าง Elixir
- Ecto.ชุดเปลี่ยน. ชุดการเปลี่ยนแปลงช่วยให้นักพัฒนาสามารถกรองและแคสต์พารามิเตอร์ภายนอกได้ เช่นเดียวกับกลไกในการติดตามและตรวจสอบการเปลี่ยนแปลงก่อนที่จะนำไปใช้กับข้อมูล
- Ecto.แบบสอบถาม. จัดเตรียมแบบสอบถาม SQL เหมือน DSL สำหรับการดึงข้อมูลจากที่เก็บ การสืบค้นข้อมูลใน Ecto มีความปลอดภัย หลีกเลี่ยงปัญหาทั่วไป เช่น SQL Injection ในขณะที่ยังสามารถเขียนได้ ทำให้นักพัฒนาสามารถสร้างการสืบค้นทีละส่วนแทนที่จะทำทั้งหมดในคราวเดียว
สำหรับบทช่วยสอนนี้ คุณจะต้อง:
- ติดตั้ง Elixir แล้ว (คู่มือการติดตั้งสำหรับ 1.2 หรือใหม่กว่า)
- ติดตั้ง PostgreSQL แล้ว
- ผู้ใช้ที่กำหนดโดยได้รับอนุญาตให้สร้างฐานข้อมูล (หมายเหตุ: เราจะใช้ผู้ใช้ “postgres” พร้อมรหัสผ่าน “postgres” เป็นตัวอย่างตลอดบทช่วยสอนนี้)
การติดตั้งและการกำหนดค่า
สำหรับผู้เริ่มต้น มาสร้างแอปใหม่กับหัวหน้างานโดยใช้ Mix Mix เป็นเครื่องมือสร้างที่มาพร้อมกับ Elixir ที่ให้งานสำหรับการสร้าง รวบรวม ทดสอบแอปพลิเคชันของคุณ จัดการการขึ้นต่อกันและอื่น ๆ อีกมากมาย
mix new cart --sup
สิ่งนี้จะสร้างไดเร็กทอรีรถเข็นพร้อมไฟล์โครงการเริ่มต้น:
* 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
เรากำลังใช้ตัวเลือก --sup
เนื่องจากเราต้องการแผนผังผู้ดูแลที่จะคอยเชื่อมต่อกับฐานข้อมูล ต่อไป เราไปที่ไดเร็กทอรี cart
ด้วย cd cart
และเปิดไฟล์ mix.exs
และแทนที่เนื้อหา:
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
ใน def application do
เราต้องเพิ่มเป็นแอปพลิเคชัน :postgrex, :ecto
เพื่อให้สามารถใช้ภายในแอปพลิเคชันของเราได้ นอกจากนี้เรายังต้องเพิ่มสิ่งเหล่านี้เป็นการพึ่งพาโดยเพิ่มใน defp deps do
postgrex (ซึ่งเป็นอะแดปเตอร์ฐานข้อมูล) และ ecto เมื่อคุณแก้ไขไฟล์แล้ว ให้รันในคอนโซล:
mix deps.get
สิ่งนี้จะติดตั้งการพึ่งพาทั้งหมดและสร้างไฟล์ mix.lock
ที่เก็บการพึ่งพาและการพึ่งพาย่อยทั้งหมดของแพ็คเกจที่ติดตั้ง (คล้ายกับ Gemfile.lock
ในบันเดิล)
Ecto.Repo
ตอนนี้เราจะมาดูวิธีกำหนด repo ในแอปพลิเคชันของเรา เราสามารถมีได้มากกว่าหนึ่ง repo ซึ่งหมายความว่าเราสามารถเชื่อมต่อกับฐานข้อมูลได้มากกว่าหนึ่งฐานข้อมูล เราจำเป็นต้องกำหนดค่าฐานข้อมูลในไฟล์ config/config.exs
:
use Mix.Config config :cart, ecto_repos: [Cart.Repo]
เราเพิ่งตั้งค่าขั้นต่ำเพื่อให้เราสามารถเรียกใช้คำสั่งถัดไปได้ ด้วยบรรทัด :cart, cart_repos: [Cart.Repo]
เรากำลังบอก Ecto ว่าเรากำลังใช้ repos ใดอยู่ นี่เป็นคุณสมบัติที่ยอดเยี่ยมเพราะช่วยให้เรามีที่เก็บถาวรจำนวนมาก กล่าวคือ เราสามารถเชื่อมต่อกับหลายฐานข้อมูลได้
ตอนนี้รันคำสั่งต่อไปนี้:
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]
คำสั่งนี้สร้าง repo หากคุณอ่านผลลัพธ์ ระบบจะแจ้งให้คุณเพิ่มหัวหน้างานและซื้อคืนในแอปของคุณ เริ่มจากหัวหน้างานกันก่อน เราจะแก้ไข 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
ในไฟล์นี้ เรากำลังกำหนดผู้ควบคุม supervisor(Cart.Repo, [])
และเพิ่มไปยังรายการย่อย (ใน Elixir รายการจะคล้ายกับอาร์เรย์) เรากำหนดกลุ่มย่อยภายใต้การดูแลด้วยกลยุทธ์ strategy: :one_for_one
ซึ่งหมายความว่าหากกระบวนการภายใต้การดูแลล้มเหลว หัวหน้างานจะรีสตาร์ทเฉพาะกระบวนการนั้นเข้าสู่สถานะเริ่มต้น คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับหัวหน้างานได้ที่นี่ หากคุณดูที่ lib/cart/repo.ex
คุณจะเห็นว่าไฟล์นี้ถูกสร้างขึ้นแล้ว ซึ่งหมายความว่าเรามี Repo สำหรับแอปพลิเคชันของเรา
defmodule Cart.Repo do use Ecto.Repo, otp_app: :cart end
ตอนนี้เรามาแก้ไขไฟล์การกำหนดค่า 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"
หลังจากกำหนดคอนฟิกูเรชันทั้งหมดสำหรับฐานข้อมูลของเราแล้ว เราก็สามารถสร้างมันขึ้นมาได้โดยการรัน:
mix ecto.create
คำสั่งนี้สร้างฐานข้อมูล และด้วยเหตุนี้ เราจึงทำการกำหนดค่าให้เสร็จสิ้น ตอนนี้เราพร้อมที่จะเริ่มเขียนโค้ดแล้ว แต่มากำหนดขอบเขตของแอปกันก่อน
การสร้างใบแจ้งหนี้ด้วยรายการแบบอินไลน์
สำหรับแอปพลิเคชันสาธิตของเรา เราจะสร้างเครื่องมือการออกใบแจ้งหนี้อย่างง่าย สำหรับเซ็ตการแก้ไข (รุ่น) เราจะมี Invoice , Item และ InvoiceItem InvoiceItem เป็นของ Invoice และ Item แผนภาพนี้แสดงให้เห็นว่าแบบจำลองของเราจะเกี่ยวข้องกันอย่างไร:
ไดอะแกรมค่อนข้างง่าย เรามีตาราง ใบแจ้งหนี้ ที่มี invoice_items จำนวนมาก ซึ่งเราเก็บรายละเอียดทั้งหมดและ รายการ ตารางที่มี invoice_items จำนวนมาก คุณจะเห็นว่าประเภทสำหรับ invoice_id และ item_id ในตาราง invoice_items คือ UUID เราใช้ UUID เพราะมันช่วยทำให้เส้นทางสับสน ในกรณีที่คุณต้องการเปิดเผยแอปผ่าน API และทำให้การซิงค์ง่ายขึ้นเนื่องจากคุณไม่ต้องพึ่งพาหมายเลขตามลำดับ ตอนนี้ มาสร้างตารางโดยใช้งานมิกซ์กัน
Ecto.การย้ายถิ่น
การย้ายคือไฟล์ที่ใช้ในการแก้ไขสกีมาฐานข้อมูล Ecto.Migration ให้ชุดวิธีในการสร้างตาราง เพิ่มดัชนี สร้างข้อจำกัด และสิ่งอื่นๆ ที่เกี่ยวข้องกับสคีมา การโยกย้ายช่วยให้แอปพลิเคชันซิงค์กับฐานข้อมูลได้อย่างแท้จริง มาสร้างสคริปต์การโยกย้ายสำหรับตารางแรกของเรา:
mix ecto.gen.migration create_invoices
สิ่งนี้จะสร้างไฟล์ที่คล้ายกับ priv/repo/migrations/20160614115844_create_invoices.exs
ซึ่งเราจะกำหนดการย้ายข้อมูลของเรา เปิดไฟล์ที่สร้างและแก้ไขเนื้อหาให้เป็นดังนี้:
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
ภายในเมธอด def change do
เรากำหนดสคีมาที่จะสร้าง SQL สำหรับฐานข้อมูล create table(:invoices, primary_key: false) do
จะสร้างตารางอิน วอยซ์ เราได้ตั้งค่า primary_key: false
แต่เราจะเพิ่มฟิลด์ ID ของประเภท UUID ฟิลด์ลูกค้าของข้อความประเภท ฟิลด์วันที่ของประเภทวันที่ วิธีการ timestamps
จะสร้างฟิลด์ inserted_at
และ updated_at
ที่ Ecto จะเติมโดยอัตโนมัติด้วยเวลาที่แทรกเรกคอร์ดและเวลาที่อัปเดตตามลำดับ ไปที่คอนโซลและเรียกใช้การย้ายข้อมูล:
mix ecto.migrate
เราได้สร้างตาราง invoice
ที่มีฟิลด์ที่กำหนดไว้ทั้งหมด มาสร้างตาราง ไอเทม กันเถอะ:
mix ecto.gen.migration create_items
ตอนนี้แก้ไขสคริปต์การโยกย้ายที่สร้างขึ้น:
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
สิ่งใหม่ที่นี่คือช่องทศนิยมที่อนุญาตให้มีตัวเลข 12 หลัก โดย 2 ตัวใช้สำหรับส่วนทศนิยมของตัวเลข เรียกใช้การย้ายข้อมูลอีกครั้ง:
mix ecto.migrate
ตอนนี้ เราได้สร้างตาราง รายการ และสุดท้าย มาสร้างตาราง invoice_items กัน:
mix ecto.gen.migration create_invoice_items
แก้ไขการย้ายข้อมูล:
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
อย่างที่คุณเห็น การโยกย้ายนี้มีบางส่วนใหม่ สิ่งแรกที่คุณจะสังเกตเห็นคือ add :invoice_id, references(:invoices, type: :uuid, null: false)
สิ่งนี้สร้างฟิลด์ invoice_id ที่มีข้อจำกัดในฐานข้อมูลที่อ้างอิงตาราง ใบแจ้งหนี้ เรามีรูปแบบเดียวกันสำหรับฟิลด์ item_id อีกสิ่งหนึ่งที่แตกต่างคือวิธีที่เราสร้างดัชนี: create index(:invoice_items, [:invoice_id])
สร้างดัชนี invoice_items_invoice_id_index
Ecto.Schema และ Ecto.Changeset
ใน Ecto นั้น Ecto.Model
เลิกใช้แล้วและหันมาใช้ Ecto.Schema
ดังนั้นเราจะเรียกโมดูลสคีมาแทนโมเดล มาสร้างชุดการเปลี่ยนแปลงกัน เราจะเริ่มต้นด้วยชุดการแก้ไข Item ที่ง่ายที่สุด และสร้างไฟล์ 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
ที่ด้านบน เราใส่โค้ดลงในเซ็ตการแก้ไขโดย use Ecto.Schema
เรายังใช้ import Ecto.Changeset
เพื่อนำเข้าฟังก์ชันการทำงานจาก Ecto.Changeset เราสามารถระบุวิธีการเฉพาะที่จะนำเข้าได้ แต่ให้ง่ายเข้าไว้ alias Cart.InvoiceItem
ช่วยให้เราสามารถเขียนโดยตรงภายในชุดการแก้ไข InvoiceItem ดังที่คุณเห็นในอีกสักครู่
Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
ระบุว่าคีย์หลักของเราจะถูกสร้างโดยอัตโนมัติ เนื่องจากเราใช้ประเภท UUID เราจึงกำหนดสคีมาด้วย schema "items" do
ของสคีมา และภายในบล็อกที่เรากำหนดแต่ละฟิลด์และความสัมพันธ์ เรากำหนด ชื่อ เป็นสตริงและ ราคา เป็นทศนิยม คล้ายกับการย้ายข้อมูลมาก ถัดไป แมโคร has_many :invoice_items, InvoiceItem
ระบุความสัมพันธ์ระหว่าง Item และ InvoiceItem เนื่องจากตามแบบแผน เราตั้งชื่อฟิลด์ item_id ในตาราง invoice_items เราจึงไม่จำเป็นต้องกำหนดค่าคีย์นอก ในที่สุด วิธีการ ประทับเวลา จะตั้งค่าฟิลด์ inserted_at และ updated_at
Ecto.Changeset
def changeset(data, params \\ %{}) do
function ได้รับ Elixir struct พร้อม params ซึ่งเราจะไพพ์ผ่านฟังก์ชันต่างๆ cast(params, @fields)
ส่งค่าเป็นประเภทที่ถูกต้อง ตัวอย่างเช่น คุณสามารถส่งเฉพาะสตริงในพารามิเตอร์ และสตริงเหล่านั้นจะถูกแปลงเป็นประเภทที่ถูกต้องที่กำหนดไว้ในสคีมา validate_required([:name, :price])
ตรวจสอบว่ามีฟิลด์ ชื่อ และ ราคา อยู่หรือไม่ validate_number(:price, greater_than_or_equal_to: Decimal.new(0))
จะตรวจสอบว่าตัวเลขนั้นมากกว่าหรือเท่ากับ 0 หรือในกรณีนี้ Decimal.new(0)
.
นั่นเป็นเรื่องที่ต้องพิจารณามาก ดังนั้น มาดูสิ่งนี้ในคอนโซลพร้อมตัวอย่างเพื่อให้คุณเข้าใจแนวคิดได้ดีขึ้น:
iex -S mix
นี่จะโหลดคอนโซล -S mix
โหลดโปรเจ็กต์ปัจจุบันลงใน 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>
ส่งคืนโครงสร้าง Ecto.Changeset
ที่ถูกต้องโดยไม่มีข้อผิดพลาด ตอนนี้ขอบันทึก:
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>}
เราไม่แสดง SQL เพื่อความกระชับ ในกรณีนี้ จะส่งคืนโครงสร้าง Cart.Item พร้อมชุดค่าทั้งหมด คุณจะเห็นว่า inserted_at และ updated_at มีการประทับเวลาและฟิลด์ id มีค่า UUID มาดูกรณีอื่นๆ กัน:
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)
ตอนนี้เราได้ตั้งค่ารายการ Scissors
ด้วยวิธีอื่น โดยกำหนดราคาโดยตรง %Cart.Item{price: Decimal.new(20)}
เราจำเป็นต้องตั้งค่าประเภทที่ถูกต้อง ไม่เหมือนกับรายการแรกที่เราเพิ่งส่งสตริงเป็นราคา เราสามารถผ่านทศนิยมและนี่จะถูกโยนเป็นประเภททศนิยม หากเราผ่าน เช่น %Cart.Item{price: 12.5}
เมื่อคุณแทรกรายการ จะมีข้อยกเว้นที่ระบุว่าประเภทไม่ตรงกัน

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>
หากต้องการยุติคอนโซล ให้กด Ctrl+C สองครั้ง คุณจะเห็นว่าการตรวจสอบใช้งานได้และราคาต้องมากกว่าหรือเท่ากับศูนย์ (0) อย่างที่คุณเห็น เราได้กำหนดสคีมาทั้งหมด Ecto.Schema ซึ่งเป็นส่วนที่เกี่ยวข้องกับวิธีการกำหนดโครงสร้างของโมดูล และเซ็ตการแก้ไข Ecto.Changeset ซึ่งเป็นการตรวจสอบและการแคสต์ทั้งหมด มาดำเนินการต่อและสร้างไฟล์ 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
ชุดการเปลี่ยนแปลงนี้มีขนาดใหญ่กว่า แต่ส่วนใหญ่แล้ว คุณน่าจะคุ้นเคยอยู่แล้ว นี่ belongs_to :invoice, Cart.Invoice, type: :binary_id
กำหนดความสัมพันธ์ "เป็นของ" กับชุดการ เปลี่ยนแปลง Cart.Invoice ที่เราจะสร้างขึ้นในไม่ช้า รายการถัดไป belongs_to :item
สร้างความสัมพันธ์กับตารางรายการ เราได้กำหนด @zero Decimal.new(0)
ในกรณีนี้ @zero เป็นเหมือนค่าคงที่ที่สามารถเข้าถึงได้ภายในโมดูล ฟังก์ชันชุดการแก้ไขมีส่วนใหม่ ซึ่งหนึ่งในนั้นคือ foreign_key_constraint(:invoice_id, message: "Select a valid invoice")
ซึ่งจะทำให้สามารถสร้างข้อความแสดงข้อผิดพลาดแทนที่จะสร้างข้อยกเว้นเมื่อไม่ปฏิบัติตามข้อจำกัด และสุดท้าย เมธอด set_subtotal จะคำนวณผลรวมย่อย เราส่งต่อชุดการแก้ไขและส่งคืนชุดการแก้ไขใหม่โดยมีผลรวมย่อยที่คำนวณหากเรามีทั้งราคาและปริมาณ
ตอนนี้ มาสร้าง Cart.Invoice กัน ดังนั้นให้สร้างและแก้ไขไฟล์ lib/cart/invoice.ex
เพื่อให้มีดังต่อไปนี้:
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
ชุดการ เปลี่ยนแปลงของ Cart.Invoice มีความแตกต่างบางประการ อันแรกอยู่ใน schema : has_many :invoice_items, InvoiceItem, on_delete: :delete_all
หมายความว่าเมื่อเราลบใบแจ้งหนี้ invoice_items ที่เกี่ยวข้องทั้งหมดจะถูกลบออก อย่างไรก็ตาม โปรดทราบว่านี่ไม่ใช่ข้อจำกัดที่กำหนดไว้ในฐานข้อมูล
ลองใช้เมธอด create ในคอนโซลเพื่อทำความเข้าใจสิ่งต่างๆ ให้ดีขึ้น คุณอาจสร้างรายการ (“กระดาษ”, “กรรไกร”) ซึ่งเราจะใช้ที่นี่:
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) }
เราดึงรายการทั้งหมดด้วย Cart.Repo.all และด้วยฟังก์ชัน Enum.map เราเพิ่งได้รับ item.id
ของแต่ละรายการ ในบรรทัดที่สอง เราเพียงแค่กำหนด id1
และ id2 ด้วย id2
ที่หนึ่งและที่สอง ตามลำดับ:
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})
มีการสร้างใบแจ้งหนี้ด้วย invoice_items และเราสามารถเรียกใบแจ้งหนี้ทั้งหมดได้ทันที
iex(4)> alias Cart.{Repo, Invoice} iex(5)> Repo.all(Invoice)
คุณสามารถเห็นว่าส่งคืน ใบแจ้งหนี้ แต่เราต้องการดู invoice_items ด้วย :
iex(6)> Repo.all(Invoice) |> Repo.preload(:invoice_items)
ด้วยฟังก์ชัน Repo.preload เราสามารถรับ invoice_items
ได้ โปรดทราบว่าการดำเนินการนี้สามารถประมวลผลการสืบค้นพร้อมกันได้ ในกรณีของฉันแบบสอบถามมีลักษณะดังนี้:
iex(7)> Repo.get(Invoice, "5d573153-b3d6-46bc-a2c0-6681102dd3ab") |> Repo.preload(:invoice_items)
Ecto.Query
จนถึงตอนนี้ เราได้แสดงวิธีการสร้างรายการใหม่และใบแจ้งหนี้ใหม่ที่มีความสัมพันธ์ แต่จะถามอะไร? ให้ฉันแนะนำคุณเกี่ยวกับ Ecto.Query ซึ่งจะช่วยให้เราสามารถค้นหาฐานข้อมูลได้ แต่ก่อนอื่น เราต้องการข้อมูลเพิ่มเติมเพื่ออธิบายให้ดีขึ้น
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")})
ตอนนี้เราควรมี 8 รายการและมี "ช็อคโกแลต" ซ้ำแล้วซ้ำอีก เราอาจต้องการทราบว่ารายการใดบ้างที่มีการทำซ้ำ ลองใช้แบบสอบถามนี้:
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"}]
คุณจะเห็นว่าในแบบสอบถาม เราต้องการส่งคืนแผนที่ที่มีชื่อของรายการและจำนวนครั้งที่ปรากฏในตารางรายการ ในทางกลับกัน เราอาจสนใจที่จะดูว่าผลิตภัณฑ์ใดขายดีที่สุด เรามาสร้างใบแจ้งหนี้กัน อันดับแรก มาทำให้ชีวิตของเราง่ายขึ้นด้วยการสร้างแผนที่เพื่อเข้าถึง 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"}
อย่างที่คุณเห็น เราได้สร้างแผนที่โดยใช้ความเข้าใจ
iex(12)> line_items = [%{item_id: items["Chocolates"], quantity: 2}]
เราจำเป็นต้องเพิ่มราคาใน invoice_items
params เพื่อสร้างใบแจ้งหนี้ แต่จะดีกว่าถ้าส่ง id ของรายการและให้ราคากรอกโดยอัตโนมัติ เราจะทำการเปลี่ยนแปลงในโมดูล Cart.Invoice เพื่อทำสิ่งนี้ให้สำเร็จ:
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
สิ่งแรกที่คุณจะสังเกตเห็นคือเราได้เพิ่ม Ecto.Query ซึ่งจะช่วยให้เราสามารถสืบค้นฐานข้อมูลได้ ฟังก์ชั่นใหม่คือ defp items_with_prices(items) do
ค้นหาผ่านรายการและค้นหาและกำหนดราคาสำหรับแต่ละรายการ
ขั้นแรก defp items_with_prices(items) do
รับรายการเป็นอาร์กิวเมนต์ ด้วย item_ids = Enum.map(items, fn(item) -> item[:item_id] || item["item_id"] end)
เราทำซ้ำรายการทั้งหมดและรับเฉพาะ item_id อย่างที่คุณเห็น เราเข้าถึงได้ทั้งด้วย atom :item_id
หรือสตริง “item_id” เนื่องจากแผนที่สามารถมีสิ่งเหล่านี้เป็นคีย์ได้ ข้อความค้นหา q = from(i in Item, select: %{id: i.id, price: i.price}, where: i.id in ^item_ids)
จะค้นหารายการทั้งหมดที่อยู่ใน item_ids
และจะส่งคืนแผนที่ด้วย item.id
และ item.price
จากนั้นเราสามารถเรียกใช้ prices = Repo.all(q)
ซึ่งส่งคืนรายการแผนที่ จากนั้นเราต้องทำซ้ำรายการและสร้างรายการใหม่ที่จะเพิ่มราคา The Enum.map(items, fn(item) ->
วนซ้ำแต่ละรายการ ค้นหาราคา Enum.find(prices, fn(p) -> p[:id] == item_id end)[:price] || 0
และสร้างรายการใหม่ด้วย item_id
ปริมาณ และราคา และด้วยเหตุนี้จึงไม่จำเป็นต้องเพิ่มราคาในแต่ละ invoice_items
อีกต่อไป
การใส่ใบแจ้งหนี้เพิ่มเติม
อย่างที่คุณจำได้ ก่อนหน้านี้เราได้สร้าง รายการ แผนที่ที่ช่วยให้เราสามารถเข้าถึง id โดยใช้ชื่อรายการสำหรับ ie items["Gum"]
“cb1c5a93-ecbf-4e4b-8588-cc40f7d12364” ทำให้ง่ายต่อการสร้าง invoice_items มาสร้างใบแจ้งหนี้กันดีกว่า เริ่มคอนโซลอีกครั้งและเรียกใช้:
Iex -S mix
iex(1)> Repo.delete_all(InvoiceItem); Repo.delete_all(Invoice)
เราลบ invoice_items และ invoice ทั้งหมดเพื่อให้มีกระดานชนวนว่างเปล่า:
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})
ตอนนี้เรามีใบแจ้งหนี้ 3 ใบ; ตัวแรกมี 2 ตัว ตัวที่สองมี 3 ตัว และตัวที่สามมี 6 ตัว ตอนนี้เราอยากรู้ว่าสินค้าตัวไหนขายดีสุด? เพื่อตอบคำถามนั้น เราจะสร้างคำถามเพื่อค้นหาสินค้าขายดีตามปริมาณและยอดรวมย่อย (ราคา x ปริมาณ)
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
เรานำเข้า Ecto.Query จากนั้นเรา alias Cart.{InvoiceItem, Item, Repo}
ดังนั้นเราจึงไม่จำเป็นต้องเพิ่ม Cart ที่จุดเริ่มต้นของแต่ละโมดูล ฟังก์ชันแรก items_by_quantity เรียกใช้ฟังก์ชัน items_by
โดยส่งพารามิเตอร์ :quantity
และเรียก Repo.all เพื่อดำเนินการค้นหา ฟังก์ชัน items_by_subtotal คล้ายกับฟังก์ชันก่อนหน้านี้ แต่ส่งผ่านพารามิเตอร์ :subtotal
ตอนนี้ขออธิบาย items_by :
-
from i in Item
แมโครนี้เลือก Item module -
join: ii in InvoiceItem, on: ii.item_id == i.id
สร้างการรวมตามเงื่อนไข “items.id = invoice_items.item_id” -
select: %{id: i.id, name: i.name, total: sum(field(ii, ^type))}
เรากำลังสร้างแผนที่ที่มีฟิลด์ทั้งหมดที่เราต้องการก่อนที่เราจะเลือก id และชื่อจากรายการ และเราทำผลรวมตัวดำเนินการ ฟิลด์ (ii, ^type) ใช้ฟิลด์แมโครเพื่อเข้าถึงฟิลด์แบบไดนามิก -
group_by: i.id
, เราจัดกลุ่มตาม items.id -
order_by: [desc: sum(field(ii, ^type))]
และสุดท้ายเรียงลำดับโดยผลรวมในลำดับจากมากไปน้อย
จนถึงตอนนี้ เราได้เขียนแบบสอบถามในรูปแบบรายการ แต่เราสามารถเขียนใหม่ในรูปแบบมาโครได้:
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
ฉันชอบที่จะเขียนแบบสอบถามในรูปแบบรายการเนื่องจากฉันพบว่าอ่านง่ายกว่า
บทสรุป
เราได้กล่าวถึงส่วนที่ดีของสิ่งที่คุณสามารถทำได้ในแอปด้วย Ecto แน่นอน ยังมีอีกมากมายที่คุณสามารถเรียนรู้ได้จากเอกสาร Ecto ด้วย Ecto คุณสามารถสร้างแอปพลิเคชันที่ทนต่อข้อผิดพลาดได้พร้อมกันโดยใช้ความพยายามเพียงเล็กน้อยที่สามารถปรับขนาดได้อย่างง่ายดายด้วยเครื่องเสมือน Erlang Ecto เป็นพื้นฐานสำหรับการจัดเก็บข้อมูลในแอปพลิเคชัน Elixir ของคุณ และมีฟังก์ชันและมาโครเพื่อจัดการข้อมูลของคุณอย่างง่ายดาย
ในบทช่วยสอนนี้ เราตรวจสอบ Ecto.Schema, Ecto.Changeset, Ecto.Migration, Ecto.Query และ Ecto.Repo แต่ละโมดูลเหล่านี้จะช่วยคุณในส่วนต่างๆ ของแอปพลิเคชันของคุณ และทำให้โค้ดมีความชัดเจนยิ่งขึ้น ดูแลรักษาและทำความเข้าใจได้ง่ายขึ้น
หากคุณต้องการดูโค้ดของบทช่วยสอน คุณสามารถค้นหาได้ที่นี่บน GitHub
หากคุณชอบบทช่วยสอนนี้และสนใจข้อมูลเพิ่มเติม ฉันจะแนะนำ Phoenix (สำหรับรายชื่อโครงการที่ยอดเยี่ยม) Awesome Elixir และการพูดคุยที่เปรียบเทียบ ActiveRecord กับ Ecto