← See all notes

Table of Contents

Elixir

Learning 🔗

Books 🔗

Templates 🔗

[tasks.watch]
# Can't get this one to work correctly 😭
# --no-process-group is apparently not supported by mise watch
# run = "mise watch format --timings --exts 'ex,exs,leex,heex,eex,sface,escript'"
run = "watchexec --no-process-group --timings --exts 'ex,exs,leex,heex,eex,sface,escript' -- 'mix format'"
alias = ["default", "w"]

[tasks.format]
run = "mix format"
alias = "f"

[tasks.test]
run = "mix test"
depends = ["compile"]
alias = "t"

[tasks.test-stale]
run = "mix test --stale"
depends = ["compile"]
alias = "ts"

[tasks.compile]
run = "mix compile"
depends = ["install"]
alias = "c"

[tasks.install]
run = "mix deps.get --all"
alias = "i"

CI 🔗

Packages 🔗

Useful blog posts / tricks 🔗

def transcribe_audio(file_path, token) do
  model = "whisper-1"
  filename = Path.basename(file_path)
  {:ok, file_contents} = File.read(file_path)

  multipart =
    Multipart.new()
    |> Multipart.add_part(Multipart.Part.text_field(model, "model"))
    |> Multipart.add_part(
      Multipart.Part.file_content_field(
        filename,
        file_contents,
        :file,
        filename: filename
      )
    )

  content_length = Multipart.content_length(multipart)
  content_type = Multipart.content_type(multipart, "multipart/form-data")

  headers = [
    {"authorization", "Bearer #{token}"},
    {"Content-Type", content_type},
    {"Content-Length", to_string(content_length)}
  ]

  Req.post(
    "<https://api.openai.com/v1/audio/transcriptions>",
    headers: headers,
    body: Multipart.body_stream(multipart)
  )
end

See what changed during a LiveView update 🔗

// Mutation observer to highlight changed elements
new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          node.style.transition = 'outline 0.3s ease-in-out';
          node.style.outline = '2px solid red';
          setTimeout(() => {
            node.style.outline = 'none';
            node.style.transition = '';
          }, 1000);
        }
      });
    }
  });
}).observe(document.body, {
  childList: true,
  subtree: true,
});

Multi-tenant query scoping 🔗

https://x.com/atomkirk/status/1806303250208927844

def ownership_query(query, _user_id) do
  from(
    u in query,
    join: mtg in assoc(u, :meetings),
    join: ans in assoc(mtg, : answers)
  )
end

defp ownership_query_for_resource(user_id, resource_id) do
  module = TypeKeyMap. module_from_id(resource_id)

  from(
    [u, ..., resource] in module.ownership_query(User, user_id),
    where: u.id == ^user_id and resource.id == ^resource_id
  )
end

See also: Scopes in Phoenix 1.8

UUID casting 🔗

https://x.com/tylerayoung/status/1818084381447033049?s=12

@doc """
Casts a UUID string to an Ecto.UUID.t().

This is a safer wrapper around `Ecto.UUID.cast/1`, which accepts either a UUID string or
a raw 16 byte UUID (like `<<0x60, 0x1D, 0x74, 0x4, 0xA8, 0X3, 0×4B, 0x6, 0x83, 0x65, 0xED,
0xDB, 0x4C, 0x89, 0x33, 0x27>>`). The latter means that *any* 16-character string
(`[email protected]`, `1234567890123456`, `warehouse worker`, etc.) will be successfully
cast, which is almost certainly not what you want.

## Examples

    iex> cast_uuid_string("a03de5ed-a9f7-436d-8644-b85e6a413e86")
    {:ok, "a03de5ed-a9f7-436d-8644-b856a413e86"}

    iex> cast_uuid_string("[email protected]")
    :error

    iex> cast_uuid_string(42)
    :error
"""
@spec cast_uuid_string(term) :: {:ok, Ecto.UUID.t()} | :error
def cast_uuid_string(value) when byte_size(value) > 16 do
  Ecto.UUID.cast(value)
end

def cast_uuid_string(_), do: :error

Elixir Concurrent Testing Architecture 🔗

https://sensaisean.medium.com/elixir-concurrent-testing-architecture-13c5e37374dc https://github.com/SophisticaSean/elixir-async-testing

@doc """
Looks up the bucket pid for `name` stored in `server`.

Returns `{:ok, pid}` if the bucket exists, `:error` otherwise.
"""
def lookup(server, name) do
  GenServer.call(server, {:lookup, name})
end

# In the test:
setup do
  registry = start_supervised!({KV.Registry, name: __MODULE__, test_pid: self()})
  %{registry: registry}
end

test "can put into bucket 1", %{registry: registry} do
  bucket_id = Ecto.UUID.generate()
  :ok = KV.Registry.create(registry, bucket_id)
  {:ok, bucket} = KV.Registry.lookup(registry, bucket_id)

  assert {:ok, _bucket} = KV.Bucket.put(bucket, "hello", 12)
  out = KV.Bucket.get(registry, bucket, "hello")
  assert "12" = out.value
end
@registry_manager Application.compile_env(:mox, :registry_manager, KV.Registry.Manager)
def lookup(name) do
  GenServer.call(@registry_manager.get_server(), {:lookup, name})
end

setup do
  registry = start_supervised!({KV.Registry, name: __MODULE__, test_pid: self()})
  Hammox.stub(KV.Registry.ManagerMock, :get_server, fn -> registry end)

  :ok
end

test "can put into bucket 1" do
  bucket_id = Ecto.UUID.generate()
  :ok = KV.Registry.create(bucket_id)
  {:ok, bucket} = KV.Registry.lookup(bucket_id)

  assert {:ok, _bucket} = KV.Bucket.put(bucket, "hello", 12)
  out = KV.Bucket.get(bucket, "hello")
  assert "12" = out.value
end
alias Ecto.Adapters.SQL.Sandbox

# ...

def start_link(opts) do
  GenServer.start_link(__MODULE__, {:ok, Keyword.get(opts, :test_pid, nil)}, opts)
end
@impl true
def init({:ok, parent_pid}) do
  if parent_pid != nil do
    :ok = Sandbox.allow(AsyncTesting.Repo, parent_pid, self())
  end
  names = %{}
  refs = %{}
  {:ok, {names, refs}}
end

Clustering 🔗

Elixir scripts 🔗

hex publish via GitHub Actions 🔗

https://x.com/emjii/status/1790853377137463522

https://github.com/elixir-mint/castore/blob/22d0a4efd41b97f55aab48e170f2520c338341b9/.github/workflows/publish.sh

Credo 🔗

Ecto generated always as identity 🔗

In config.exs add:

config :my_app, MyApp.Repo,
  migration_primary_key: [type: :"bigint generated always as identity"]

Using a VERSION file 🔗

defp project_version do
  __ENV__.file
  |> Path.dirname()
  |> Path.join("VERSION")
  |> File.read!()
  |> String.trim()
end

Mix aliases 🔗

defp aliases do
  [
    "ecto.setup": [
      "ecto.create",
      "ecto.load --skip-if-loaded --quiet",
      "ecto.migrate",
      "run priv/repo/seeds.exs"
    ],
    "ecto.migrate": ["ecto.migrate", "ecto.dump"],
    "ecto.rollback": ["ecto.rollback", "ecto.dump"],
    test: [
      "ecto.create --quiet",
      "ecto.load --quiet --skip-if-loaded",
      "ecto.migrate --quiet",
      "test"
    ],
    dump_migrations: ["ecto.dump", &delete_migration_files/1],
    # Deploy from: <https://x.com/wojtekmach/status/1783072561850401021>
    deploy: ["deploy.check", &deploy_confirm/1, "deploy.run"],
    "deploy.check": [
      "cmd git status --porcelain | grep . && echo \\"'error: Working directory is dirty'\\" && exit 1 || exit 0",
      "cmd mix test --warnings-as-errors"
    ],
    "deploy.run": ["cmd flyctl deploy --build-arg APP_SHA=`git rev-parse --short HEAD`"],
    "deploy.console" ["cmd flyctl ssh console --pty --command=\\"'/app/bin/app_name remote'\\""]
  ]
end

defp delete_migration_files(_args) do
  # Match all files in the 21st century (year is 20xx).
  Enum.each(Path.wildcard("priv/repo/migrations/20*.exs"), fn migration_file ->
    File.rm!(migration_file)
    Mix.shell().info([:bright, "Deleted: ", :reset, :red, migration_file])
  end)
end

defp deploy_confirm(_) do
  unless Mix.shell().yes?("Ready to deploy?") do
    IO.puts("error: User cancelled")
    System.halt(1)
  end
end

Repo.transact 🔗

From: https://tomkonidas.com/repo-transact/

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @doc """
  A small wrapper around `Repo.transaction/2'.

  Commits the transaction if the lambda returns `:ok` or `{:ok, result}`,
  rolling it back if the lambda returns `:error` or `{:error, reason}`. In both
  cases, the function returns the result of the lambda.
  """
  @spec transact((-> any()), keyword()) :: {:ok, any()} | {:error, any()}
  def transact(fun, opts \\\\ []) do
    transaction(
      fn ->
        case fun.() do
          {:ok, value} -> value
          :ok -> :transaction_commited
          {:error, reason} -> rollback(reason)
          :error -> rollback(:transaction_rollback_error)
        end
      end,
      opts
    )
  end
end

Repo.fetch 🔗

From: https://tomkonidas.com/repo-fetch/

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @spec fetch(Ecto.Queryable.t(), binary(), keyword()) ::
          {:ok, Ecto.Schema.t()} | {:error, :not_found}
  def fetch(queryable, id, opts \\\\ []) do
    case get(queryable, id, opts) do
      nil -> {:error, :not_found}
      record -> {:ok, record}
    end
  end

  @spec fetch_by(Ecto.Queryable.t(), keyword() | map(), keyword()) ::
          {:ok, Ecto.Schema.t()} | {:error, :not_found}
  def fetch_by(queryable, clauses, opts \\\\ []) do
    case get_by(queryable, clauses, opts) do
      nil -> {:error, :not_found}
      record -> {:ok, record}
    end
  end
end

Unnest 🔗

From: https://kobrakai.de/kolumne/unnest-for-runtime-sorted-results

defmodule MyApp.QueryHelpers do
  @doc """
  Unnest into a table format.

  ## Example

      import MyApp.QueryHelpers

      status = [:waiting, :running, :done]
      order = [1, 2, 3]

      from jobs in "jobs",
        join: ordering in unnest(^status, ^order),
        on: jobs.status == ordering.a,
        order_by: ordering.b

  """
  defmacro unnest(list_a, list_b) do
    quote do
      fragment("select * from unnest(?, ?) AS t(a, b)", unquote(list_a), unquote(list_b))
    end
  end
end

Unnest multi-update 🔗

From: https://geekmonkey.org/updating-multiple-records-with-different-values-in-ecto-repo-update_all

Tree
|> join(
  :inner
  [t],
  tn in fragment(
    "SELECT * FROM unnest(?::integer[], ?::text[])
    AS id_to_new_name(id, new_name)",
    type(^ids, {:array, :integer}),
    type(^new_names, {:array, :string})
  ),
  on: t.id == tn.id
)
|> update([_, tn], set: [name: tn.new_name])
|> DB.Repo.update_all([])

Scroll into view on reload 🔗

A little snippet I put into my LiveView apps to make development easier.

Add an id to an element and included it in the URL hash to have the element scroll into view on every reload

From: https://x.com/atimberlake/status/1823319021992763403

window.addEventListener('phx:page-loading-stop', (_info) => {
    if (document.location.hash) {
        const cid = document.location.hash.substring(1);
        const el = document.getElementById(cid);
        if (el) {
            el.scrollIntoView({ behavior: 'smooth' });
        }
    }
});

Optimize compile times 🔗

Identify runtime dependencies causing large compilation cycles 🔗

Before this commit, LivebookWeb had runtime dependencies into the project, causing large compilation cycles.

Using the following command in Elixir v1.17.3+

$ mix xref graph --format stats --label compile-connected

Would reveal: Top 10 files with most incoming dependencies:

After this patch:

Top 10 files with most incoming dependencies:

From: https://github.com/livebook-dev/livebook/commit/edefa6649ab783c1c2f6a2c067b44fa6dc9de642

Validating function keyword list input 🔗

If your function accepts a keyword list of options, you can use Keyword.validate!/2 to:

  1. Validate that you only received the expected keys, and
  2. Set default values for any or all of your expected keys

For instance, this will raise a really clear error:

iex> Keyword.validate!([falback: 123], [:fallback, :timeout, :max_size])
** (ArgumentError) unknown keys [:falback] in [falback: 123], the allowed keys are: [:fallback, :timeout, :max_size]

Here’s what it looks like to get back an updated keyword list with your default values interpolated into it:

iex> Keyword.validate!([fallback: 123], [fallback: nil, timeout: 5_000, max_size: 100])
[fallback: 123, timeout: 5_000, max_size: 100]

Alias module within its definition 🔗

# Before
defmodule MyApp.Businesses.Business do
  def open(%__MODULE__{} = business) do
    Map.put(business, :open, true)
  end

  def close(%__MODULE__{} = business) do
    Map.put(business, :open, false)
  end
end

# After
defmodule MyApp.Businesses.Business do
  alias __MODULE__

  def open(%Business{} = business) do
    Map.put(business, :open, true)
  end

  def close(%Business{} = business) do
    Map.put(business, :open, false)
  end
end

Update LiveView node, but ignore specific attributes 🔗

https://x.com/thmsmlr/status/1901081275844194343

let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
  dom: {
    onBeforeElUpdated(fromEl, toEl) {
      if (fromEl.hasAttribute("phx-update-ignore")) {
        const ignoreAttributes = fromEl.getAttribute("phx-update-ignore").split(",");
        ignoreAttributes.forEach(attr => {
          if (fromEl.hasAttribute(attr)) {
            toEl.setAttribute(attr, fromEl.getAttribute(attr));
          }
        });
      }
    }
  }
});

Disable os_mon in tests 🔗

In test.exs:

# Disable os_mon for tests
config :os_mon,
  start_cpu_sup: false,
  start_memsup: false

Jump into editor from iex 🔗

Close iex 🔗

Ctrl + \\

Make private functions available in dev/test 🔗

defmodule MyModule do
  if Mix.env() in [:dev, :test] do
    @compile :export_all
  end
end