author: Kate Travers author_link: https://github.com/ktravers categories: til tags: [‘ecto’] date: 2019-08-23 layout: post title: TIL How to Select Merge with Ecto.Query excerpt: >

Before you reach for adding another association to your schema, consider using Ecto.Query#select_merge/3 with a virtual field instead.

TIL 如何使用 Ecto.Query 选择合并

在使用 Elixir 和 Ecto 的过程中,我遇到过这样的情况:我需要从一个表中检索数据,也许还要从一个未关联的表中检索一两个字段。过去,每当发生这种情况时,我通常会做一些我不是很满意的事情—也许是更新模式,把它分成多个查询,或者是建立一个多查询语句,如果我觉得很有创意的话。

值得高兴的是,今天我知道了有一个更好的方法。你可以用 Ecto.Query#select_merge/3 用一个查询表达式来实现同样的最终结果。

让我们通过一个例子来看看它的操作。

设置

假设你在一所有招生部门的学校工作,你的任务是显示与某一招生有关的所有事件,组织成三列。1) 日期 2) 采取的行动 3) 谁采取的行动。

Date Action Taken By
8/1/2019 Student Admitted Albus Dumbledore

首先,我们有一个 AdmissionEvent schema,看起来像这样。

  1. defmodule Registrar.Tracking.AdmissionEvent do
  2. use Ecto.Schema
  3. schema "admission_events" do
  4. field(:action, :string)
  5. field(:admission_id, :integer)
  6. field(:admitter_uuid, Ecto.UUID)
  7. field(:occurred_at, :naive_datetime)
  8. end
  9. end

…然后 User schema 看起来像这样:

  1. defmodule Registrar.User do
  2. use Ecto.Schema
  3. schema "users" do
  4. field(:uuid, Ecto.UUID)
  5. field(:full_name, :string)
  6. end
  7. end

…为了完整起见,还提供了一个超级简单的 Admission schema:

  1. defmodule Registrar.Admission do
  2. use Ecto.Schema
  3. schema "admission" do
  4. field(:admittee_uuid, Ecto.UUID)
  5. field(:admitter_uuid, Ecto.UUID)
  6. field(:admitted_at, :naive_datetime)
  7. end
  8. end

这里的问题是,录取者的全名存在于 User 中,而用户目前并没有与 AdmissionEvent 关联。因此,如果我们直接执行选择查询,我们最终会得到我们需要填充表的录取事件,但不会得到录取者的全名。

  1. defmodule Registrar.Tracking.AdmissionEvent do
  2. use Ecto.Schema
  3. import Ecto.Query, only: [from: 2]
  4. alias Registrar.Admission
  5. alias Registrar.Tracking.AdmissionEvent
  6. schema "admission_events" do
  7. field(:action, :string)
  8. field(:admission_id, :integer)
  9. field(:admitter_uuid, Ecto.UUID)
  10. field(:occurred_at, :naive_datetime)
  11. end
  12. def for_admission(query \\ AdmissionEvent, %Admission{} = admission) do
  13. from(ae in query,
  14. where: ae.admission_id == ^admission.id,
  15. order_by: [desc: ae.occurred_at]
  16. )
  17. end
  18. end
  1. # Taking our query function for a spin...
  2. iex> admission = Repo.get(Admission, 1)
  3. iex> AdmissionEvent.for_admission(admission) |> Repo.all()
  4. [
  5. %Registrar.Tracking.AdmissionEvent{
  6. __meta__: %Ecto.Schema.Metadata<:loaded, "admission_events">,
  7. action: "Student Admitted",
  8. admission_id: 3,
  9. admitter_uuid: "7edd4d7f-a790-41f9-b4ef-16f1dc3b33ea",
  10. id: 1,
  11. occurred_at: ~N[2019-07-29 02:22:18]
  12. }
  13. ]

那么,我们要如何去获取全名呢?我们有很多选项可以选择,但在这篇文章中,我们将比较两个选项:一个是大家可能会首先使用的选项(添加关联和预加载数据),另一个是我们希望今后更经常使用的选项(Ecto.Query#select_merge/3)。

选项 1: 添加关联和预加载数据

如果我们把 User 和 AdmissionEvent 关联起来,那么我们就可以预先加载关联的 User 记录,直接从那里读取全名。

  1. defmodule Registrar.Tracking.AdmissionEvent do
  2. use Ecto.Schema
  3. import Ecto.Query, only: [from: 2]
  4. alias Registrar.Tracking.AdmissionEvent
  5. alias Registrar.User
  6. schema "admission_events" do
  7. field(:action, :string)
  8. field(:admission_id, :integer)
  9. field(:admitter_uuid, Ecto.UUID)
  10. field(:occurred_at, :naive_datetime)
  11. # New association
  12. belongs_to(:admitter, User, foreign_key: :uuid)
  13. end
  14. end
  15. defmodule Registrar.User do
  16. use Ecto.Schema
  17. alias Registrar.Tracking.AdmissionEvent
  18. schema "users" do
  19. field(:uuid, Ecto.UUID)
  20. field(:full_name, :string)
  21. # New association
  22. has_many(:admission_events, AdmissionEvent)
  23. end
  24. end
  1. # Trying out our new association...
  2. iex> admission = Repo.get(Admission, 1)
  3. iex> events = AdmissionEvent.for_admission(admission) |> Repo.all() |> Repo.preload(:admitter)
  4. [
  5. %Registrar.Tracking.AdmissionEvent{
  6. __meta__: %Ecto.Schema.Metadata<:loaded, "admission_events">,
  7. action: "Student Admitted",
  8. admission_id: 3,
  9. admitter: %Registrar.User{
  10. __meta__: %Ecto.Schema.Metadata<:loaded, "users">,
  11. id: 1,
  12. name: "Albus Dumbledore",
  13. uuid: "7edd4d7f-a790-41f9-b4ef-16f1dc3b33ea"
  14. },
  15. admitter_uuid: "7edd4d7f-a790-41f9-b4ef-16f1dc3b33ea",
  16. id: 1,
  17. occurred_at: ~N[2019-07-29 02:22:18]
  18. }
  19. ]
  20. iex> event = List.first(events)
  21. iex> event.admitter.name
  22. "Albus Dumbledore"

这种方法可以完成工作,但有点沉重。我们只需要录取者的全名,为什么要检索整个 User 结构?你也可以看到这种模式如何导致一个超级混乱的 User schema。现在它有很多 admission_events ,但很快它可能有很多application_eventsinterview_eventsbilling_events 等等。

选项 2: ✨ 查询 合并 ✨

Ecto.Query#select_merge/3 给了我们一个更简洁、更精确的选择。看看这个丝滑的例子。

  1. defmodule Registrar.Tracking.AdmissionEvent do
  2. use Ecto.Schema
  3. import Ecto.Query, only: [from: 2]
  4. alias Registrar.Admission
  5. alias Registrar.User
  6. alias Registrar.Tracking.AdmissionEvent
  7. schema "admission_events" do
  8. field(:action, :string)
  9. field(:admission_id, :integer)
  10. field(:admitter_uuid, Ecto.UUID)
  11. field(:occurred_at, :naive_datetime)
  12. ###### STEP ONE #######
  13. # Add Virtual Field #
  14. #######################
  15. field(:admitter_name, :string, virtual: true)
  16. end
  17. def for_admission(query \\ AdmissionEvent, %Admission{} = admission) do
  18. from(ae in query,
  19. where: ae.admission_id == ^admission.id,
  20. order_by: [desc: ae.occurred_at],
  21. #### STEP TWO ####
  22. # Join on User #
  23. ##################
  24. join: u in User,
  25. on: ae.admitter_uuid == u.uuid,
  26. ############ STEP THREE #############
  27. # Select Merge into Virtual Field #
  28. #####################################
  29. select_merge: %{admitter_name: u.full_name}
  30. )
  31. end
  32. end
  1. # Trying out select merge...
  2. iex> admission = Repo.get(Admission, 1)
  3. iex> AdmissionEvent.for_admission(admission) |> Repo.all()
  4. [
  5. %Registrar.Tracking.AdmissionEvent{
  6. __meta__: %Ecto.Schema.Metadata<:loaded, "admission_events">,
  7. action: "Student Admitted",
  8. admission_id: 3,
  9. admitter_name: "Albus Dumbledore",
  10. id: 1,
  11. occurred_at: ~N[2019-07-29 02:22:18]
  12. }
  13. ]
  14. iex> event = List.first(events)
  15. iex> event.admitter_name
  16. "Albus Dumbledore"

通过添加一个虚拟字段并将其填充到 select_merge 中,我们最终得到了一个更轻量级的解决方案。我们不需要添加任何新的关联就能获得我们所需要的数据,保持我们的 schema 解耦。另外,如果我们需要为不同类型的事件引入事件日志,我们有一个可遵循的模式,这个模式的扩展性更强。

总结

Ecto.Query#select_merge/3 允许我们在选择查询中直接填充一个虚拟字段,使我们在设计 shcema 和组成查询时具有各种灵活性。

100% 会再次编写。

资源