Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/helpers/record_reader.ex
defmodule AshDispatch.Helpers.RecordReader do
@moduledoc """
Defensive field-reading helpers for post-action notifier hooks.
## Why this exists
Ash actions can use narrowed `select` clauses to return only a subset
of attributes. The excluded attributes are populated as
`%Ash.NotLoaded{}` sentinels rather than raising or returning `nil`.
Notifier hooks (counter broadcasts, locale extraction, dispatch
context building) read fields off the action's resulting record to
decide what to do next. A bare `Map.get(record, field)` returns the
sentinel for narrowed fields, which then cascades into:
- `Ecto.Query.CastError` when used in a query filter
- `ArgumentError "Actor field is not loaded"` from
`Ash.Query.relates_to_actor_via` checks
- `Protocol.UndefinedError{protocol: String.Chars}` when interpolated
into a topic string or log message
- Truthy-check bugs: `if Map.get(record, :foo)` is `true` for a
sentinel because it's a struct, not nil/false
## The contract
Notifier side effects are best-effort — they must degrade silently
when the record can't yield the field, not crash the request or
spam the log. These helpers treat `%Ash.NotLoaded{}` as `nil` so
callers' existing nil-checks make the right decision automatically.
Single debug log per call so production diagnostics are still
available without polluting test output.
"""
require Logger
@doc """
Like `Map.get/2` but treats `%Ash.NotLoaded{}` as `nil`.
Emits a single debug-level log line when the sentinel is observed,
to aid diagnosing select-narrowed actions in production.
"""
@spec safe_get(map(), atom() | String.t()) :: term() | nil
def safe_get(record, field) do
case Map.get(record, field) do
%Ash.NotLoaded{} ->
Logger.debug(fn ->
struct_name =
case record do
%{__struct__: mod} -> inspect(mod)
_ -> "record"
end
"[AshDispatch] field #{inspect(field)} not loaded on #{struct_name}; " <>
"treating as nil (action likely used a narrowed select)."
end)
nil
other ->
other
end
end
@doc """
`safe_get/2` with a default returned when the field is `nil` or
`%Ash.NotLoaded{}`.
"""
@spec safe_get(map(), atom() | String.t(), term()) :: term()
def safe_get(record, field, default) do
case safe_get(record, field) do
nil -> default
val -> val
end
end
@doc """
Returns `true` if the field is present on the record AND has been
loaded (i.e. is not the `%Ash.NotLoaded{}` sentinel).
"""
@spec loaded?(map(), atom() | String.t()) :: boolean()
def loaded?(record, field) do
case Map.get(record, field, :__missing__) do
:__missing__ -> false
%Ash.NotLoaded{} -> false
_ -> true
end
end
end