View Source Associations

Dx allows to easily traverse associations to access fields or even predicates defined on associated records.

Say our Todo.List schema from the previous guide now has_many tasks:

defmodule Todo.List do
  use Ecto.Schema
  use Dx.Ecto.Schema, repo: Todo.Repo

  schema "lists" do
    field :archived_at, :utc_datetime

    has_many :tasks, Todo.Task
  end

  infer archived?: false, when: %{archived_at: nil}
  infer archived?: true

  infer state: :archived, when: %{archived?: true}
  infer state: :active
end

In return, we add a Task schema that belongs_to a List:

defmodule Todo.Task do
  use Ecto.Schema
  use Dx.Ecto.Schema, repo: Todo.Repo

  schema "tasks" do
    field :completed_at, :utc_datetime

    belongs_to :list, Todo.List
  end

  infer completed?: false, when: %{completed_at: nil}
  infer completed?: true
end

belongs_to

Say we want a Task to be archived? when the List it belongs to is archived. We could write a similar rule on the Todo.Task schema as we have on the List:

  infer archived?: false, when: %{list: %{archived_at: nil}}
  infer archived?: true

The archived? predicate looks at the associated list (defined using belongs_to) and its field archived_at, and compares that to nil. If it's nil then the Task's predicate archived? is false, otherwise it's true.

However, since we've already defined this logic on the List, we can also use the predicate on the associated List instead, and change things around a bit:

  infer archived?: true, when: %{list: %{archived?: true}}
  infer archived?: false

Usage

Like before, we can use Dx.get!/2 to evaluate the predicate, but only if the association is (pre)loaded:

iex> list = %Todo.List{archived_at: ~U[2022-02-02 22:22:22Z]} |> Todo.Repo.insert!()
...> %Todo.Task{completed_at: nil, list: list}
...> |> Dx.get!(:archived?)
true

If the association is not (pre)loaded, Dx.get!/2 will raise an error:

iex> list = %Todo.List{archived_at: ~U[2022-02-02 22:22:22Z]} |> Todo.Repo.insert!()
...> %Todo.Task{completed_at: nil, list: list}
...> |> Todo.Repo.insert!() |> Todo.Repo.reload!()  # insert and reload without associations
...> |> Dx.get!(:archived?)
** (Dx.Error.NotLoaded) Association list is not loaded on nil. Cannot get path: nil

To allow Dx to load associations as needed, use Dx.load!/2 instead:

iex> list = %Todo.List{archived_at: ~U[2022-02-02 22:22:22Z]} |> Todo.Repo.insert!()
...> %Todo.Task{completed_at: nil, list: list}
...> |> Todo.Repo.insert!() |> Todo.Repo.reload!()  # insert and reload without associations
...> |> Dx.load!(:archived?)
# loads the associated list
true

has_many

We can also define predicates based on a has_many association. Dx generally treats conditions on a list of records like an Enum.any? condition:

defmodule Todo.List do
  # ...

  infer in_progress?: true, when: %{tasks: %{completed?: true}}
  infer in_progress?: false
end

The predicate in_progress? is true if there's any Task associated that has completed?: true. Otherwise, if there's no Task associated that has completed?: true, in_progress? is false.

Putting it all together, we can extend our state predicate on the Todo.List schema:

defmodule Todo.List do
  # ...

  infer state: :archived, when: %{archived?: true}
  infer state: :in_progress, when: %{tasks: %{completed?: true}}
  infer state: :ready, when: %{tasks: %{}}
  infer state: :empty
end

What does the :ready rule do? It checks whether there's any Task, without any condition on the Task. So if the List is not archived, and there are no completed tasks, but there is a Task, :state is :ready. Otherwise :state is :empty.

This might be hard to grasp, but it will hopefully become clearer in the next guide...