AshIntrospection.Manifest.Decorator (AshIntrospection v0.4.1)

Copy Markdown View Source

Walks a freshly generated %Ash.Info.Manifest{} once, at compile time, and writes everything this library reads under custom.<namespace>.

This is the only module in lib/ that is meant to call Ash.Resource.Info during decoration, and the only one that writes :custom. AshIntrospection.Manifest.Custom reads it back. The point of the split is that the request path stops introspecting: live introspection is not deleted by adopting a manifest, it is relocated to one compile-time pass.

Why the Ash structs are carried, not rebuilt

A %Ash.Info.Manifest.Field{} is a client-facing description. It carries a resolved %Ash.Info.Manifest.Type{} and has_default?, where this library's readers return %Ash.Resource.Attribute{} and its callers read .type, .constraints and .defaultAshIntrospection.Codegen.ActionIntrospection pattern-matches the struct itself (lib/ash_introspection/codegen/action_introspection.ex:225), and AshIntrospection.Codegen.ValidationErrorTypes.classify_action_input_errors/3 hands it back to the caller. Rebuilding one from a manifest field would be lossy — default is not on the manifest at all — and would change a return type consumers already depend on.

So the decorator captures the live struct. What it computes is the data that is genuinely derived and genuinely repeated per request: resolved aggregate types, each action's return classification, the bulk-authorization strategy, client-facing field and argument names under each built-in formatter and their reverse, how each :many relationship paginates, and an entrypoint lookup keyed by client-facing name.

Changing those return types is a separate, breaking decision. It belongs to the stage that deletes AshIntrospection.Codegen.TypeDiscovery, not to this additive one. See docs/decisions.md.

What is written where

StructPayload
%Ash.Info.Manifest{}entrypoint_lookup
each %Manifest.Resource{}fields, actions, aggregate types, return classifications, bulk strategy, field and argument names
each %Manifest.Relationship{} on itthe read action it loads through, and how that action paginates
each embedded %Manifest.Type{}'s nested resourcethe same resource payload
each %Manifest.Type{} with a field-names callbackfield-name mappings
each %Manifest.Entrypoint{}client_name, from the :entrypoint_name callback

Decoration depends on compile order, and says so

Every payload is read off a module atom the manifest names, so that module has to be compiled when decorate/3 runs. Under parallel compilation it may not be. Rather than write wrong data, this module skips a module it cannot load — Code.ensure_loaded?/1 guards every such read, the repo-wide rule from #49 — and the reader falls back to live Ash.Resource.Info for anything undecorated. That is safe but silent, which is exactly the staleness trap issue #23 records against a naive port of upstream's 8c07331.

The caller owns the compile edges. The transformer that calls this function must force every referenced module to compile first, and must give the manifest module a compile-time dependency on the domains it was built from. Neither belongs here; both are stage 3 of #23. Nothing in this module makes them harder — decorate/3 is a pure function of its arguments.

Namespace

decorate/3 takes the namespace as an argument rather than hard-coding one, because whether generators share :ash_introspection or each decorate under their own key is still open on #23. AshIntrospection.Manifest.Custom defaults to :ash_introspection and so does this module.

Summary

Types

The slice of the pipeline/codegen config map the decorator reads.

Functions

Returns manifest with custom[namespace] populated on every struct this library reads.

Types

config()

@type config() :: %{
  optional(:format_field_for_client) => (atom(), module() | nil, atom() ->
                                           String.t()),
  optional(:get_original_field_name) => (module(), String.t() -> atom() | nil),
  optional(:entrypoint_name) => (module(), atom() -> String.t() | nil),
  optional(:field_names_callback) => atom(),
  optional(:output_field_formatter) => atom()
}

The slice of the pipeline/codegen config map the decorator reads.

These are the callbacks the consumer already threads through AshIntrospection.Rpc.Pipeline; the decorator calls each once per field at compile time instead of once per field per request.

Functions

decorate(manifest, namespace \\ Custom.default_namespace(), config \\ %{})

@spec decorate(Ash.Info.Manifest.t(), atom(), config()) :: Ash.Info.Manifest.t()

Returns manifest with custom[namespace] populated on every struct this library reads.

Pure: same manifest and config in, same manifest out. Call it once, at compile time, on the result of Ash.Info.Manifest.generate/1.

config is the pipeline config map. Every key is optional; with an empty map the decorator computes client names with AshIntrospection.FieldFormatter.format_field_name/2, which is what the pipeline does when the consumer supplies no callback.