Ambient.Value (Ambient v0.1.0)

Copy Markdown View Source

Build your own overridable value on top of Ambient.ProcessOverride.

An ambient value is one resolved implicitly from the surrounding context rather than threaded through arguments. Ambient.Config, Ambient.Clock, Ambient.Random and Ambient.Env are all built on this module – nothing about them is privileged. If your app has an ambient value of its own (the current tenant, the acting user, a request id), this is the supported way to make it as testable as the built-in ones.

defmodule MyApp.Tenant do
  use Ambient.Value, table: :my_app_tenant_overrides

  @doc "The tenant for the current process, or the default."
  def current, do: get_or(:tenant, MyApp.Tenant.Default)

  @doc "Pin the tenant for this test and everything it spawns."
  def put(tenant), do: put_override(:tenant, tenant)
end

Register it once in test/test_helper.exs, exactly like a built-in:

Ambient.start_servers([Ambient.Clock, MyApp.Tenant])

What you get

use Ambient.Value, table: :some_table defines:

  • get_or/2 (imported macro) – the read. Returns the override if one is in scope, otherwise evaluates the fallback expression.
  • @ambient_enabled – whether this build compiled the machinery in, for values that need to drop a branch of their own.
  • put_override/2, delete_override/1, delete_all/0 – the writers.
  • overridden?/1 – whether an override is in scope for a key.
  • allow/2 – grant a process outside the $callers chain access.
  • set_shared/1, set_private/0 – shared mode for async: false tests.
  • __ambient_table__/0 – so Ambient.start_servers/1 accepts the module.

All of them are defoverridable. allow/2 and set_shared/1 take a defaulted second/first argument, so allow/1 and set_shared/0 are exported too.

A module holding a single value conventionally uses one sentinel key (:clock, :tenant); one holding many (like Ambient.Config) keys by name.

get_or/2 is a macro, on purpose

It expands at compile time, so in a build that didn't opt into overrides (see Ambient.ProcessOverride) the whole lookup disappears and only the fallback expression remains:

def current, do: get_or(:tenant, MyApp.Tenant.Default)
# in a production build, compiles to exactly:
def current, do: MyApp.Tenant.Default

That is what keeps a wrapper free to use everywhere in production code. The fallback is only evaluated when there is no override, so get_or(:key, expensive_call()) doesn't pay for the call it doesn't need.

Summary

Functions

Read the override for key, falling back to fallback when there is none.

Functions

get_or(key, fallback)

(macro)

Read the override for key, falling back to fallback when there is none.

Imported by use Ambient.Value, and resolved against that module's table. A macro rather than a function: in a build without overrides compiled in it expands to fallback alone, so the wrapper costs nothing in production.

def utc_now, do: get_or(:clock, DateTime.utc_now())