Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/helpers/resource_introspection.ex
defmodule AshDispatch.Helpers.ResourceIntrospection do
@moduledoc """
Helpers for introspecting Ash resources to derive configuration automatically.
These helpers enable zero-configuration setups by examining resource
relationships and attributes to infer settings that would otherwise
require explicit configuration.
## Usage
alias AshDispatch.Helpers.ResourceIntrospection
# Find the user_id field on a resource
ResourceIntrospection.derive_user_id_path(MyApp.Orders.Order)
#=> [:user_id]
# Find all user relationships
ResourceIntrospection.find_user_relationships(MyApp.Tickets.Ticket)
#=> [%{name: :user, source_attribute: :user_id}, %{name: :assigned_to, source_attribute: :assigned_to_id}]
## Ambiguity Handling
When a resource has multiple relationships to the user module, these helpers
return `nil` and log a warning. In such cases, explicit configuration is required.
"""
alias AshDispatch.Config
require Logger
@doc """
Derives the user_id_path by introspecting the resource's relationships.
Finds the `belongs_to` relationship that points to the configured `user_module`
and returns its `source_attribute` as a single-element list (e.g., `[:user_id]`).
## Return Values
- `[:field_name]` - Single unambiguous user relationship found
- `nil` - No user relationship, multiple relationships (ambiguous), or error
## Examples
# Single user relationship
derive_user_id_path(MyApp.Notifications.Notification)
#=> [:user_id]
# Multiple relationships (logs warning)
derive_user_id_path(MyApp.Tickets.Ticket) # has :user and :assigned_admin
#=> nil
# No user relationship
derive_user_id_path(MyApp.Settings.Config)
#=> nil
## Configuration
Requires `:user_module` to be configured:
config :ash_dispatch,
user_module: MyApp.Accounts.User
"""
@spec derive_user_id_path(module()) :: [atom()] | nil
def derive_user_id_path(resource) do
derive_user_id_path(resource, nil)
end
@doc """
Derives user_id_path with audience-aware disambiguation.
When a resource has multiple user relationships, this function can auto-select
the correct one if the audience name matches a relationship name.
## Examples
# Ticket has :user, :started_by, :resolved_by, :closed_by
derive_user_id_path(Ticket, :user)
#=> [:user_id] # Auto-picks :user because audience matches
derive_user_id_path(Ticket, :resolved_by)
#=> [:resolved_by_id] # Auto-picks :resolved_by
derive_user_id_path(Ticket, :admin)
#=> nil # No match, warns with guidance
## Parameters
- `resource` - The Ash resource module
- `audience` - The audience atom (e.g., `:user`, `:admin`). Pass `nil` for legacy behavior.
## Return Values
- `[:field_name]` - Relationship found (single or matched by audience)
- `nil` - No relationship, ambiguous without match, or error
"""
@spec derive_user_id_path(module(), atom() | nil) :: [atom()] | nil
def derive_user_id_path(resource, audience) do
case find_user_relationships(resource) do
[] ->
nil
[rel] ->
[rel.source_attribute]
multiple ->
# Try to disambiguate using audience name
matching_rel = Enum.find(multiple, fn rel -> rel.name == audience end)
cond do
# Audience matches a relationship name - use it
matching_rel ->
[matching_rel.source_attribute]
# MFA-based audiences handle their own user resolution - no user_id_path needed
is_mfa_audience?(audience) ->
nil
# No match and not MFA - warn with helpful guidance
true ->
rel_names = Enum.map(multiple, & &1.name)
Logger.warning("""
[ResourceIntrospection] Ambiguous user relationships on #{inspect(resource)}.
Found multiple belongs_to relationships to user module: #{inspect(rel_names)}
To fix, add explicit user_id_path in your counter DSL:
counter :my_counter,
audience: :user,
user_id_path: [:user_id] # or [:#{Enum.at(rel_names, 1)}_id], etc.
""")
nil
end
end
rescue
error ->
Logger.warning(
"[ResourceIntrospection] Failed to derive user_id_path from #{inspect(resource)}: #{inspect(error)}"
)
nil
end
@doc """
Finds all `belongs_to` relationships that point to the configured user module.
Returns a list of relationship structs with their metadata.
## Examples
find_user_relationships(MyApp.Tickets.Ticket)
#=> [
# %{name: :user, source_attribute: :user_id, type: :belongs_to},
# %{name: :assigned_to, source_attribute: :assigned_to_id, type: :belongs_to}
# ]
find_user_relationships(MyApp.Settings.Config)
#=> []
"""
@spec find_user_relationships(module()) :: [map()]
def find_user_relationships(resource) do
user_module = Config.user_module()
if is_nil(user_module) do
[]
else
resource
|> Ash.Resource.Info.relationships()
|> Enum.filter(fn rel ->
rel.type == :belongs_to && rel.destination == user_module
end)
|> Enum.map(fn rel ->
%{
name: rel.name,
source_attribute: rel.source_attribute,
type: rel.type,
destination: rel.destination
}
end)
end
rescue
_error ->
[]
end
@doc """
Resolves the user_id_path for counter scoping.
This function consolidates the logic for determining how to scope counter queries:
1. **scope provided** → Scope expression takes precedence (returns nil, scope handles it)
2. **explicit user_id_path** → Use the configured path
3. **auto-derive** → Introspect resource relationships
Note: `authorize?` is used for Ash policy authorization but does NOT affect user scoping.
A counter can have `authorize?: false` (bypass policies) while still having user-scoped data
via `user_id_path`.
## Parameters
- `resource` - The Ash resource module
- `opts` - Options keyword list with:
- `:scope` - Explicit scope expression (Ash.Expr)
- `:user_id_path` - Explicit path to user_id field
- `:audience` - Audience atom for relationship disambiguation
## Examples
# User counter with explicit path (authorize?: false bypasses policies but still scopes to user)
resolve_user_id_path_for_scoping(RoomMember, user_id_path: [:user_id])
#=> [:user_id]
# Auto-derive from relationship
resolve_user_id_path_for_scoping(Order, audience: :user)
#=> [:user_id] # if Order has belongs_to :user
"""
@spec resolve_user_id_path_for_scoping(module(), keyword()) :: [atom()] | nil
def resolve_user_id_path_for_scoping(resource, opts) do
scope = Keyword.get(opts, :scope)
explicit_path = Keyword.get(opts, :user_id_path)
audience = Keyword.get(opts, :audience)
cond do
# Layer 1: Explicit scope expression takes precedence
scope ->
nil
# Layer 2: Use explicit user_id_path if provided (regardless of authorize?)
explicit_path ->
explicit_path
# Layer 3: Auto-derive from resource relationships
true ->
derive_user_id_path(resource, audience)
end
end
@doc """
Checks if a resource has any relationship to the user module.
## Examples
has_user_relationship?(MyApp.Orders.Order)
#=> true
has_user_relationship?(MyApp.Settings.Config)
#=> false
"""
@spec has_user_relationship?(module()) :: boolean()
def has_user_relationship?(resource) do
find_user_relationships(resource) != []
end
@doc """
Builds a filter expression for user_id based on a path.
Converts a path like `[:user_id]` or `[:cart, :user_id]` into an Ash-compatible
filter keyword list that can be used with `Ash.Query.filter/2`.
## Examples
# Simple path
build_user_filter([:user_id], "user-123")
#=> [user_id: "user-123"]
# Nested path (through relationship)
build_user_filter([:cart, :user_id], "user-123")
#=> [cart: [user_id: "user-123"]]
# Deeply nested
build_user_filter([:order, :cart, :user_id], "user-123")
#=> [order: [cart: [user_id: "user-123"]]]
"""
@spec build_user_filter([atom()], String.t()) :: keyword()
def build_user_filter([field], user_id) do
[{field, user_id}]
end
def build_user_filter([relationship | rest], user_id) do
nested_filter = build_user_filter(rest, user_id)
[{relationship, nested_filter}]
end
@doc """
Determines if an audience is relationship-based or filter-based.
This distinction is important for counter recipient resolution:
- **Relationship-based** (bare atom in config): Extract recipient from the record itself
- **Filter-based** (tuple in config): Query all users matching the filter
## Audience Config Pattern
The audience configuration in `config :ash_dispatch, :audiences` uses this convention:
audiences: [
:user, # Bare atom = relationship-based
{:admin, [:user, {:admin, true}]}, # Tuple = filter-based
{:partner, [:partner]} # Tuple with relationship path
]
## Examples
is_relationship_audience?(:user)
#=> true (extract from record's :user relationship)
is_relationship_audience?(:admin)
#=> false (query all users where admin: true)
is_relationship_audience?(:custom)
#=> true (not in config, assume relationship-based for backward compat)
## Use Cases
For counters:
- Relationship-based audience → broadcast to record owner only
- Filter-based audience → broadcast to ALL matching users
For events:
- Both use the same resolution, but relationship-based extracts from context
"""
@spec is_relationship_audience?(atom()) :: boolean()
def is_relationship_audience?(audience_name) do
audiences_config = Config.audiences()
cond do
# Bare atom in config list = relationship-based
Enum.member?(audiences_config, audience_name) -> true
# Tuple/keyword entry = filter-based
Keyword.has_key?(audiences_config, audience_name) -> false
# Not in config - assume relationship-based (backward compatibility)
# This allows custom audiences to work without explicit config
true -> true
end
end
@doc """
Checks if an audience is configured as an MFA (Module, Function, Args) tuple.
MFA-based audiences handle their own user resolution via the configured function,
so they don't need `user_id_path` for scoping. This is used to suppress
"ambiguous user relationships" warnings for resources with multiple user
relationships when using MFA audiences.
## Audience Config Pattern
MFA audiences are configured as 3-tuples in the audiences config:
audiences: [
:user, # Bare atom - relationship-based
admin: [:user, admin: true], # List - filter-based
company_members: {MyApp.AudienceResolver, :company_members, [:resource]} # MFA tuple
]
## Examples
is_mfa_audience?(:company_members)
#=> true (configured as MFA tuple)
is_mfa_audience?(:user)
#=> false (bare atom)
is_mfa_audience?(:admin)
#=> false (filter-based list)
is_mfa_audience?(:unknown)
#=> false (not in config)
"""
@spec is_mfa_audience?(atom() | nil) :: boolean()
def is_mfa_audience?(nil), do: false
def is_mfa_audience?(audience_name) do
audiences_config = Config.audiences()
case Keyword.get(audiences_config, audience_name) do
{module, function, args} when is_atom(module) and is_atom(function) and is_list(args) ->
true
_ ->
false
end
end
@doc """
Returns the relationship name for a relationship-based audience.
For bare atom audiences, the audience name IS the relationship name.
For filter-based audiences, extracts the first atom from the config path.
## Examples
get_audience_relationship(:user)
#=> :user
get_audience_relationship(:admin) # config: {:admin, [:user, {:admin, true}]}
#=> :user (follows :user relationship, then filters)
get_audience_relationship(:partner) # config: {:partner, [:partner]}
#=> :partner
"""
@spec get_audience_relationship(atom()) :: atom() | nil
def get_audience_relationship(audience_name) do
audiences_config = Config.audiences()
cond do
# Bare atom = relationship name is the audience name
Enum.member?(audiences_config, audience_name) ->
audience_name
# Tuple format: {:admin, [:user, {:admin, true}]}
# First atom in the list is the relationship
true ->
case Keyword.get(audiences_config, audience_name) do
[rel | _rest] when is_atom(rel) -> rel
_ -> nil
end
end
end
@doc """
Parses an audience config list into relationship path and filter components.
This is the unified parsing logic used by both counter and event resolution.
## Examples
# New format with relationship path
parse_audience_config([:user, {:admin, true}])
#=> {[:user], [admin: true]}
# Relationship chain (no filter)
parse_audience_config([:user, :associated_seller])
#=> {[:user, :associated_seller], []}
# Legacy format (filter only)
parse_audience_config([{:admin, true}])
#=> {[], [admin: true]}
# Empty config
parse_audience_config([])
#=> {[], []}
## Return Value
Returns a tuple `{relationship_path, filter}` where:
- `relationship_path` is a list of atoms representing relationships to follow
- `filter` is a keyword list of filter conditions
"""
@spec parse_audience_config(list()) :: {[atom()], keyword()}
def parse_audience_config(config) when is_list(config) do
# Split into relationship path (bare atoms) and filter (keyword pairs)
Enum.split_while(config, fn
item when is_atom(item) -> true
{_key, _value} -> false
end)
end
def parse_audience_config(_), do: {[], []}
@doc """
Extracts just the filter from an audience config.
Convenience function that returns only the filter portion, discarding
the relationship path. Useful for checking if a user matches an audience.
## Examples
extract_audience_filter([:user, {:admin, true}])
#=> [admin: true]
extract_audience_filter([:user])
#=> []
"""
@spec extract_audience_filter(list()) :: keyword()
def extract_audience_filter(config) do
{_path, filter} = parse_audience_config(config)
filter
end
end