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