AshDispatch.Helpers.ResourceIntrospection (AshDispatch v0.5.1)

View Source

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.

Summary

Functions

Builds a filter expression for user_id based on a path.

Derives the user_id_path by introspecting the resource's relationships.

Derives user_id_path with audience-aware disambiguation.

Extracts just the filter from an audience config.

Finds all belongs_to relationships that point to the configured user module.

Returns the relationship name for a relationship-based audience.

Checks if a resource has any relationship to the user module.

Checks if an audience is configured as an MFA (Module, Function, Args) tuple.

Determines if an audience is relationship-based or filter-based.

Parses an audience config list into relationship path and filter components.

Resolves the user_id_path for counter scoping.

Functions

build_user_filter(list, user_id)

@spec build_user_filter([atom()], String.t()) :: keyword()

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"]]]

derive_user_id_path(resource)

@spec derive_user_id_path(module()) :: [atom()] | nil

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

derive_user_id_path(resource, audience)

@spec derive_user_id_path(module(), atom() | nil) :: [atom()] | nil

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

extract_audience_filter(config)

@spec extract_audience_filter(list()) :: keyword()

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])
#=> []

find_user_relationships(resource)

@spec find_user_relationships(module()) :: [map()]

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)
#=> []

get_audience_relationship(audience_name)

@spec get_audience_relationship(atom()) :: atom() | nil

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

has_user_relationship?(resource)

@spec has_user_relationship?(module()) :: boolean()

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

is_mfa_audience?(audience_name)

@spec is_mfa_audience?(atom() | nil) :: boolean()

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)

is_relationship_audience?(audience_name)

@spec is_relationship_audience?(atom()) :: boolean()

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

parse_audience_config(config)

@spec parse_audience_config(list()) :: {[atom()], keyword()}

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

resolve_user_id_path_for_scoping(resource, opts)

@spec resolve_user_id_path_for_scoping(
  module(),
  keyword()
) :: [atom()] | nil

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