พบกับ Ecto ฐานข้อมูล Wrapper ที่ไม่มีการประนีประนอมสำหรับแอพ Elixir พร้อมกัน

เผยแพร่แล้ว: 2022-03-11

Ecto เป็นภาษาเฉพาะโดเมนสำหรับเขียนข้อความค้นหาและโต้ตอบกับฐานข้อมูลในภาษา Elixir เวอร์ชันล่าสุด (2.0) รองรับ PostgreSQL และ MySQL (รองรับ MSSQL, SQLite และ MongoDB ในอนาคต) ในกรณีที่คุณยังใหม่ต่อ Elixir หรือมีประสบการณ์เพียงเล็กน้อย เราขอแนะนำให้คุณอ่าน Kleber Virgilio Correia's Getting Started with Elixir Programming Language

เบื่อภาษา SQL ทั้งหมดหรือไม่? พูดคุยกับฐานข้อมูลของคุณผ่าน Ecto
ทวีต

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) .

ใน Elixir การดำเนินการทศนิยมทำได้แตกต่างออกไปเนื่องจากถูกนำไปใช้เป็นโครงสร้าง

นั่นเป็นเรื่องที่ต้องพิจารณามาก ดังนั้น มาดูสิ่งนี้ในคอนโซลพร้อมตัวอย่างเพื่อให้คุณเข้าใจแนวคิดได้ดีขึ้น:

 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