<!-- livebook:{"file_entries":[{"name":"dx_demo_task_lists_v1.sqlite","type":"url","url":"https://s3.eu-central-1.amazonaws.com/elixir-dx-demo/dx_demo_task_lists_v1.sqlite"}]} -->

# Dx Demo

```elixir
Mix.install(
  [
    {:dx, "~> 0.3.0"},
    {:kino, "~> 0.11"},
    {:ecto_dbg, "~> 0.4"},
    {:ecto_erd, "~> 0.5"},
    {:ecto_sqlite3, "~> 0.13"}
  ],
  config: [dx: [repo: Repo]]
)

Logger.configure(level: :warning)
```

## Repo setup

```elixir
defmodule Repo do
  use Ecto.Repo,
    otp_app: :demo,
    adapter: Ecto.Adapters.SQLite3,
    database: Kino.FS.file_path("dx_demo_task_lists_v1.sqlite")
end
```

```elixir
defmodule Repo.QueryLogger do
  require Logger

  def handle_event([:repo, :query], _measurements, metadata, _config) do
    sql = EctoDbg.inline_params(metadata.query, metadata.params, metadata.repo.__adapter__())

    case SqlFmt.format_query(sql) do
      {:ok, formatted} -> IO.puts(formatted <> "\n\n")
      _else -> :ok
    end
  end
end

:telemetry.detach("ecto-queries")
:ok = :telemetry.attach("ecto-queries", [:repo, :query], &Repo.QueryLogger.handle_event/4, nil)
```

```elixir
{:ok, conn} = Kino.start_child({Repo, database: Kino.FS.file_path("dx_demo_task_lists_v1.sqlite")})

Repo.query!("PRAGMA table_list")
```

## Data schema

````elixir
defmodule Schema.User do
  use Ecto.Schema
  use Dx.Ecto.Schema, repo: Repo

  schema "users" do
    field(:email, :string)
    field(:verified_at, :utc_datetime)

    field(:first_name, :string)
    field(:last_name, :string)

    has_many(:lists, Schema.List, foreign_key: :created_by_id)
    belongs_to(:role, Schema.Role)
  end
end

defmodule Schema.Role do
  use Ecto.Schema
  use Dx.Ecto.Schema, repo: Repo

  schema "roles" do
    field(:name, :string)

    has_many(:users, Schema.User)
  end
end

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

  schema "lists" do
    field(:title, :string)

    belongs_to(:created_by, Schema.User)
    belongs_to(:from_template, Schema.ListTemplate)
    has_many(:tasks, Schema.Task)

    field(:archived_at, :utc_datetime)
    field(:hourly_points, :float)
    timestamps()
  end
end

defmodule Schema.ListTemplate do
  use Ecto.Schema
  use Dx.Ecto.Schema, repo: Repo

  schema "list_templates" do
    field(:title, :string)

    field(:hourly_points, :float)

    has_many(:lists, Schema.List, foreign_key: :from_template_id)
  end
end

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

  schema "tasks" do
    field(:title, :string)
    field(:desc, :string)

    belongs_to(:list, Schema.List)
    belongs_to(:created_by, Schema.User)

    field(:due_on, :date)
    field(:completed_at, :utc_datetime)
    field(:archived_at, :utc_datetime)
    timestamps()
  end
end

diagram =
  [Schema.User, Schema.Role, Schema.List, Schema.ListTemplate, Schema.Task]
  |> Ecto.ERD.Document.render(".mmd", &Function.identity/1, [])

Kino.Markdown.new("""
```mermaid
#{diagram}
```
""")
````

## Dx basics

Within defd functions, you can write Elixir code as if all associations are already (pre)loaded:

```elixir
defmodule Core.Users do
  import Dx.Defd

  defd get_author_names(tasks) do
    Enum.map(tasks, & &1.created_by.last_name)
  end
end

require Dx.Defd

tasks = Repo.all(Schema.Task)
Dx.Defd.load!(Core.Users.get_author_names(tasks))
```

defd functions can call other defd functions, so you can structure your code into functions and modules as usual:

```elixir
defmodule Core.Users2 do
  import Dx.Defd

  defd get_author_names(tasks) do
    Enum.map(tasks, &author_last_name/1)
  end

  defd author_last_name(task) do
    task.created_by.last_name
  end
end

Core.Users2.get_author_names(tasks)
```

## Use case: Authorization

You can also pass schema modules to Enum functions to query additional data, which is not in associations.

Data is only queried when needed, for example when an if matches.

```elixir
defmodule Core.Authorization do
  import Dx.Defd

  defd visible_lists(user) do
    if admin?(user) do
      Enum.filter(Schema.List, &(&1.title == "Main list"))
    else
      user.lists
    end
  end

  defd admin?(user) do
    user.role.name == "Admin"
  end

  defd get_an_admin() do
    Enum.find(Schema.User, &admin?/1)
  end

  defd get_users_visible_lists(users) do
    Enum.map(users, &{&1.id, visible_lists(&1)})
  end
end

admin = Dx.Defd.load!(Core.Authorization.get_an_admin())
Dx.Defd.load!(Core.Authorization.visible_lists(admin))
```

This can just as well be run for multiple users. Queries are batched automatically, so it's efficient.

```elixir
Repo.all(Schema.User)
|> Core.Authorization.get_users_visible_lists()
|> Dx.Defd.load!()
```

## Use case: Detect dormant users

This logic is entirely translated to SQL:

```elixir
defmodule Core.User.Filters do
  import Dx.Defd

  defd dormant_users(min_days_old) do
    threshold = DateTime.shift(DateTime.utc_now(), day: -min_days_old)

    Schema.User
    |> Enum.filter(&(Enum.count(&1.lists) == 0))
    |> Enum.filter(&DateTime.before?(&1.verified_at, threshold))
  end
end

require Dx.Defd

Dx.Defd.load!(Core.User.Filters.dormant_users(90))
```

## Use case: Commands

Since defd functions can be run more often than if it was non-defd, it's a good practice to separate data reading/querying from data manipulation/updating.

This example thus returns atoms to determine what should happen next, instead of directly performing the actions. This makes it a pure function and easier to reason about and to test.

```elixir
defmodule Core.Workflow do
  import Dx.Defd

  defd next_action(user) do
    cond do
      not verified?(user) ->
        :send_verification_reminder

      Enum.count(user.lists) == 0 ->
        :send_beginner_tutorial

      not Enum.any?(user.lists, fn list -> Enum.any?(list.tasks, &task_completed?/1) end) ->
        :send_advanced_tutorial

      true ->
        nil
    end
  end

  defd task_completed?(task), do: not is_nil(task.completed_at)

  defd verified?(user), do: not is_nil(user.verified_at)
end
```