AshIntrospection.ResourceInfo (AshIntrospection v0.4.0)

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 in this stage

Stage 1 installs the seam; it does not finish the migration. A function is backed by the manifest here only where both sources return the identical value:

FunctionManifest-backed
runtime_resource?/2, declared_resource?/2, embedded?/2yes
primary_key/2, identity_keys/3yes
relationship/3, public_relationship/3yes
public_field_names/2yes
every attribute, calculation, aggregate, action readerno — live either way

The rest read live even when a manifest is present, and say so below. The reason is shape, not effort: Ash.Info.Manifest.Field carries a resolved %Ash.Info.Manifest.Type{} where Ash.Resource.Attribute carries an Ash type module plus a constraints keyword list. Translating between them is a real piece of work with its own failure modes, and it is what stage 2's decorator exists for. Half-translating it here would put a second, quieter answer next to the live one.

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

Is module part of the declared API surface?

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/1.

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.

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. Live in this stage.

actions(resource, config \\ %{})

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

See Ash.Resource.Info.actions/1. Live in this stage.

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

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

See Ash.Resource.Info.aggregate/2. Live in this stage.

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

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

See Ash.Resource.Info.aggregate_type/2. Live in this stage.

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

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

See Ash.Resource.Info.attribute/2. Live in this stage.

attributes(resource, config \\ %{})

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

See Ash.Resource.Info.attributes/1. Live in this stage.

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

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

See Ash.Resource.Info.calculation/2. Live in this stage.

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.

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/1.

Call it once at an entry point rather than preparing on every read. A config with no :manifest key is returned untouched.

prepare(manifest)

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.

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. Live in this stage.

public_aggregates(resource, config \\ %{})

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

See Ash.Resource.Info.public_aggregates/1. Live in this stage.

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. Live in this stage.

public_attributes(resource, config \\ %{})

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

See Ash.Resource.Info.public_attributes/1. Live in this stage.

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. Live in this stage.

public_calculations(resource, config \\ %{})

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

See Ash.Resource.Info.public_calculations/1. Live in this stage.

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.

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.