<!-- 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
```