AshIntrospection.ResourceInfo (AshIntrospection v0.4.1)

Copy Markdown View Source

The one place this library asks what a resource looks like.

Every Ash.Resource.Info call in lib/ goes through this module. That is the whole point of it: issue #23 replaces live introspection with the precomputed Ash.Info.Manifest upstream ash_typescript adopted, and a migration spread across 64 call sites in nine modules is not reviewable. One reader makes each later stage a small diff.

Two sources, one contract

Reads take the config map the pipeline and codegen already thread, and look for an optional :manifest key — the same shape :load_restrictions and :is_interop_resource? use.

ConfigSource
no :manifest key, or nillive Ash.Resource.Info, exactly as before
%Ash.Info.Manifest{}the manifest, lookups rebuilt per read
AshIntrospection.ResourceInfo.Sourcethe manifest, lookups built once

Omitting the key preserves current behaviour exactly. That is the compatibility guarantee of this stage and the reason it is reversible: no caller in this repo passes :manifest yet, so every read is still live. test/ash_introspection/resource_info_test.exs proves it against Ash.Resource.Info directly rather than asserting it.

resource?/1 is two questions, not one

Ash.Resource.Info.resource?/1 answers a fact about a module. Ash.Info.Manifest.has_resource?/2 answers a fact about the declared API surface. They are not the same question, and 18 of the 64 sites ask it, so this module refuses to guess which one a caller meant:

  • runtime_resource?/2 — manifest first, live Ash.Resource.Info when the module is absent from it. For the request path, where the argument can be a runtime value.__struct__ that Ash handed back. A module nobody declared must still serialize as a resource; answering false would silently drop it into the generic Map.from_struct/1 branch.

  • declared_resource?/2 — the manifest is the whole answer when one is present; absence means false. For codegen, where scoping is the point: a resource nobody exposed should not appear in generated output.

Both count embedded resources. Ash.Info.Manifest.Generator splits them out of resources and re-enters them under types with kind: :embedded_resource, so a bare has_resource?/2 would answer false for every embedded resource — a divergence from live introspection that has nothing to do with scoping. The only difference between the two functions is what a missing module means.

Everything else defaults to the fallback reading. A site is given declared_resource?/2 only when it is unambiguously codegen.

What the manifest answers

Every reader is manifest-backed, and they divide into two groups by what the manifest has to carry for them to be:

FunctionAnswered from
runtime_resource?/2, declared_resource?/2, embedded?/2the manifest as ash generates it
primary_key/2, identity_keys/3the manifest as ash generates it
relationship/3, public_relationship/3the manifest as ash generates it
public_field_names/2the manifest as ash generates it
every attribute, calculation, aggregate, action readercustom.<namespace>, written by AshIntrospection.Manifest.Decorator
aggregate_type/3, authorize_bulk_strategy/2custom.<namespace>

The second group needs decoration because a generated manifest cannot answer it. %Ash.Info.Manifest.Field{} is a client-facing description: it carries a resolved %Ash.Info.Manifest.Type{} where callers here read {type, constraints}, and has_default? where they read default. The decorator captures the live struct once, at compile time, so the answer is identical rather than approximated. test/ash_introspection/manifest/ proves that field for field.

A manifest is not a complete list of relationships. Ash.Info.Manifest.Generator.generate/1 defaults :include_private_relationships? to false, so relationship/3 falls back to live on a miss — a private belongs_to is absent from the manifest and present in Ash.Resource.Info. public_relationship/3 does not fall back: every public relationship is carried, so a miss there is the answer.

An undecorated manifest still reads live for the second group. A resource the decorator skipped — a module it could not load at decoration time — is present in the manifest and bare, and is indistinguishable to these readers from a resource the manifest never carried. Both fall back.

Shapes the two sources cannot share

Where the native return values differ, this module returns a narrow map with only the keys its call sites read, and both sources build it. relationship/3 is the case: live returns %Ash.Resource.Relationships.HasOne{} and friends, the manifest returns %Ash.Info.Manifest.Relationship{}. Callers here read :destination and :cardinality and nothing else, so that is what comes back. identity_keys/3 is the same narrowing over %Ash.Resource.Identity{} versus the manifest's %{keys: [...]}.

Not :resource_info_module

The :resource_info_module config key names the consumer's generated Info module, used for interop_resource?/1 and get_original_field_name/2. It is unrelated to this module and to :manifest.

Summary

Types

The slice of the pipeline/codegen config map this module reads.

A relationship narrowed to the keys this library reads.

Functions

Whether a bulk update or destroy on resource should authorize with :error or :filter.

Is module part of the declared API surface?

The decorated %Ash.Info.Manifest.Resource{} for resource and the namespace it was decorated under, or nil to read live.

Is module an embedded resource?

The key names of the identity identity_name on resource, or nil.

Normalizes the :manifest key on a config map with prepare/2.

Builds the lookup maps for a manifest once, so reads do not rebuild them.

The primary key field names of resource.

The names of every public field on resource — attributes, calculations and aggregates together.

The public relationship name on resource, narrowed like relationship/3.

The relationship name on resource, narrowed to :name, :destination and :cardinality, or nil.

How the read behind the :many relationship name paginates.

The read action the :many relationship name loads through, or nil.

Is module a resource, falling back to live introspection when the manifest does not know it?

Returns the prepared manifest source on config, or nil for live reads.

Types

config()

@type config() :: %{
  optional(:manifest) =>
    Ash.Info.Manifest.t() | AshIntrospection.ResourceInfo.Source.t() | nil
}

The slice of the pipeline/codegen config map this module reads.

Every other key is ignored.

relationship()

@type relationship() :: %{
  name: atom(),
  destination: module(),
  cardinality: :one | :many
}

A relationship narrowed to the keys this library reads.

Functions

action(resource, name, config \\ %{})

@spec action(module(), atom(), config()) :: Ash.Resource.Actions.action() | nil

See Ash.Resource.Info.action/2.

actions(resource, config \\ %{})

@spec actions(module(), config()) :: [Ash.Resource.Actions.action()]

See Ash.Resource.Info.actions/1.

aggregate(resource, name, config \\ %{})

@spec aggregate(module(), atom() | String.t(), config()) ::
  Ash.Resource.Aggregate.t() | nil

See Ash.Resource.Info.aggregate/2.

aggregate_type(resource, aggregate, config \\ %{})

@spec aggregate_type(module(), Ash.Resource.Aggregate.t(), config()) :: term()

See Ash.Resource.Info.aggregate_type/2.

The decorator resolves this once per aggregate at compile time; live, it walks the relationship path to the aggregated field on every call. The {:ok, type} / {:error, reason} return shape is that function's and is passed through unchanged.

attribute(resource, name, config \\ %{})

@spec attribute(module(), atom() | String.t(), config()) ::
  Ash.Resource.Attribute.t() | nil

See Ash.Resource.Info.attribute/2.

attributes(resource, config \\ %{})

@spec attributes(module(), config()) :: [Ash.Resource.Attribute.t()]

See Ash.Resource.Info.attributes/1.

authorize_bulk_strategy(resource, config \\ %{})

@spec authorize_bulk_strategy(module(), config()) :: :error | :filter

Whether a bulk update or destroy on resource should authorize with :error or :filter.

:error when the data layer can express errors in expressions, :filter otherwise — Ash.DataLayer.data_layer_can?(resource, :expr_error). A data layer is fixed at compile time, so the decorator answers this once.

calculation(resource, name, config \\ %{})

@spec calculation(module(), atom() | String.t(), config()) ::
  Ash.Resource.Calculation.t() | nil

See Ash.Resource.Info.calculation/2.

declared_resource?(module, config \\ %{})

@spec declared_resource?(term(), config()) :: boolean()

Is module part of the declared API surface?

With a manifest, absence is the answer: a module nobody exposed is not a resource here. Without one, this is Ash.Resource.Info.resource?/1. For codegen only.

decoration(resource, config \\ %{})

@spec decoration(module(), config()) :: {Ash.Info.Manifest.Resource.t(), atom()} | nil

The decorated %Ash.Info.Manifest.Resource{} for resource and the namespace it was decorated under, or nil to read live.

For the modules that read decorated data this one does not wrap — AshIntrospection.Codegen.ActionIntrospection and its return classification. They still go through AshIntrospection.Manifest.Custom to read it; this only finds the struct.

nil covers three cases a caller treats identically: no manifest, a manifest that does not carry the module, and a manifest that carries it undecorated.

embedded?(module, config \\ %{})

@spec embedded?(term(), config()) :: boolean()

Is module an embedded resource?

Manifest first, live when the module is absent from it — embedded resources live in manifest.types with kind: :embedded_resource, not in manifest.resources.

identity_keys(resource, identity_name, config \\ %{})

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

The key names of the identity identity_name on resource, or nil.

A narrowing: live introspection returns %Ash.Resource.Identity{} and the manifest returns %{keys: [...]}. Every caller here reads :keys.

normalize_config(config)

@spec normalize_config(map()) :: map()

Normalizes the :manifest key on a config map with prepare/2.

Call it once at an entry point rather than preparing on every read. A config with no :manifest key is returned untouched. An optional :manifest_namespace key names the decoration namespace.

prepare(manifest, namespace \\ nil)

Builds the lookup maps for a manifest once, so reads do not rebuild them.

Accepts a %Ash.Info.Manifest{}, an already prepared AshIntrospection.ResourceInfo.Source, or nil. Returns nil unchanged, so it is safe to call on a config value that may be absent.

namespace names the custom key AshIntrospection.Manifest.Decorator decorated under. nil keeps a prepared source's own namespace and gives a bare manifest the default.

primary_key(resource, config \\ %{})

@spec primary_key(module(), config()) :: [atom()]

The primary key field names of resource.

Returns [] for a module the manifest does not carry, matching Ash.Info.Manifest.primary_key/2.

public_aggregate(resource, name, config \\ %{})

@spec public_aggregate(module(), atom() | String.t(), config()) ::
  Ash.Resource.Aggregate.t() | nil

See Ash.Resource.Info.public_aggregate/2.

public_aggregates(resource, config \\ %{})

@spec public_aggregates(module(), config()) :: [Ash.Resource.Aggregate.t()]

See Ash.Resource.Info.public_aggregates/1.

public_attribute(resource, name, config \\ %{})

@spec public_attribute(module(), atom() | String.t(), config()) ::
  Ash.Resource.Attribute.t() | nil

See Ash.Resource.Info.public_attribute/2.

public_attributes(resource, config \\ %{})

@spec public_attributes(module(), config()) :: [Ash.Resource.Attribute.t()]

See Ash.Resource.Info.public_attributes/1.

public_calculation(resource, name, config \\ %{})

@spec public_calculation(module(), atom() | String.t(), config()) ::
  Ash.Resource.Calculation.t() | nil

See Ash.Resource.Info.public_calculation/2.

public_calculations(resource, config \\ %{})

@spec public_calculations(module(), config()) :: [Ash.Resource.Calculation.t()]

See Ash.Resource.Info.public_calculations/1.

public_field_names(resource, config \\ %{})

@spec public_field_names(module(), config()) :: [atom()]

The names of every public field on resource — attributes, calculations and aggregates together.

One Map.keys/1 on the manifest path; three list walks on the live one.

public_relationship(resource, name, config \\ %{})

@spec public_relationship(module(), atom(), config()) :: relationship() | nil

The public relationship name on resource, narrowed like relationship/3.

The manifest carries only public relationships, so both sources agree.

relationship(resource, name, config \\ %{})

@spec relationship(module(), atom(), config()) :: relationship() | nil

The relationship name on resource, narrowed to :name, :destination and :cardinality, or nil.

A miss on the manifest falls back to live introspection, because a manifest is not a complete list of relationships: Ash.Info.Manifest.Generator.generate/1 defaults :include_private_relationships? to false (deps/ash/lib/ash/info/manifest/generator.ex:50), so a private belongs_to is absent from a manifest built with the defaults while Ash.Resource.Info.relationship/2 still answers for it.

relationship_pagination(resource, name, config \\ %{})

@spec relationship_pagination(module(), atom(), config()) ::
  :offset | :keyset | :mixed | :none

How the read behind the :many relationship name paginates.

:offset, :keyset, :mixed when the action offers both, and :none when it offers neither — which is also the answer for every to-one relationship and for a relationship nobody declared.

Live, this walks to the destination's read action on every call. The decorator resolves it once per relationship at compile time. Precomputed for issue #24, the relationship query envelopes, which need the answer per relationship per request.

relationship_read_action(resource, name, config \\ %{})

@spec relationship_read_action(module(), atom(), config()) :: atom() | nil

The read action the :many relationship name loads through, or nil.

The relationship's own read_action when it names one, the destination's primary read otherwise. nil for a to-one relationship, for a destination with no primary read, and for a relationship nobody declared.

runtime_resource?(module, config \\ %{})

@spec runtime_resource?(term(), config()) :: boolean()

Is module a resource, falling back to live introspection when the manifest does not know it?

For the request path. See the module doc for why this is not the same question as declared_resource?/2.

source(config)

@spec source(config()) :: AshIntrospection.ResourceInfo.Source.t() | nil

Returns the prepared manifest source on config, or nil for live reads.