Flicker.Providers.AshResource (Flicker v0.1.0)

Copy Markdown View Source

The built-in Flicker.Provider that Tier 1 declarative component config compiles to (ADR-001) — searching and fetching directly off an Ash resource, with no provider module of your own to write.

This module only compiles when ash is present (guarded by Code.ensure_loaded?/1, ADR-006): it's inert, not merely undocumented, in a build without ash in its deps.

Every emitted Flicker.Result carries the underlying record as meta.record — the documented, stable way for an :option slot to render icons, badges, or anything else the two label strings can't express.

Configure it with {Flicker.Providers.AshResource, opts}:

  • :resource — required. The Ash resource module to read.
  • :search — required list of attribute names search/2's ilike-style (case-insensitive substring) match runs over.
  • :option_label — required. An attribute name, or a fun(record) :: String.t(), producing each result's :label.
  • :option_sublabel — optional. An attribute name, or a fun(record) :: String.t() | nil, producing each result's :sublabel.
  • :read_action — the read action to run. Defaults to :read.
  • :read_action_args — arguments for :read_action; :query is replaced with the typed search text.
  • :fetch_action — read action used by fetch/2. Defaults to :read.
  • :load — loads applied to both searches and selected-value fetches.
  • :limit — max results for search/2. Defaults to 25.
  • :sort — sort applied to search/2's query (any Ash.Query.sort/2 input). Optional.
  • :filter — a base filter (any Ash.Query.filter_input/2 input) applied before the search-text match. Optional.
  • :facets — optional list of facet keys for facets/1 to expand (Spec 003), and the registry search/2 composes Flicker.Query.to_filter/2 against to scope candidates by query.facets before the free-text match runs. Each entry is either a bare atom (:status) naming an attribute, relationship, aggregate, or calculation to derive a Flicker.Facet.t() from, a {key, overrides} pair (status: [type: :enum]) where overrides — any of :type, :path, :attribute, :aggregate, :op, :label — takes precedence over what introspection would derive, or an already-built Flicker.Facet.t() (passed through unchanged — how Flicker.select/1's facets: attr wires this in, since it resolves to Flicker.Facet structs before reaching the provider).

:actor, :tenant, and :limit also arrive per-call, merged in by Flicker.Provider.run_search/3 / run_fetch/3 — reads are always actor/tenant-scoped (ADR-004): this provider passes them straight through to Ash.Query.for_read/4 and Ash.read/2, and never bypasses policies.

fetch/2 resolves all of values in a single primary_key in ^values read (ADR-003) — one query, not one per value — and silently omits values that don't resolve (deleted records, or records a policy now hides): this is a normal partial result, not an error.

Composite primary keys aren't supported yet — fetch/2 assumes a single-attribute primary key.

Summary

Functions

Counts how many records each requested facet's values would match (Spec 021).

Expands opts[:facets] into [Flicker.Facet.t()] by introspecting opts[:resource] — the facet registry (Spec 003).

Resolves values against opts[:resource]'s primary key in a single primary_key in ^values read (ADR-003). Values that don't resolve are simply absent from the result — not an error.

Runs opts[:read_action] on opts[:resource], filtering by opts[:filter] (if any), then query.facets (Spec 003 — distinct facet keys AND, repeated instances of the same key OR, composed via Flicker.Query.to_filter/2 against this provider's own facet registry so the candidates are scoped before the text match runs), then query.text (case-insensitive substring match over opts[:search]), sorted by opts[:sort] (if any), limited to opts[:limit] and offset by opts[:offset] (default 0, via Ash.Query.offset/2 — Spec 010). Always actor/tenant-scoped (ADR-004).

Functions

facet_counts(query, facets, opts)

@spec facet_counts(Flicker.Query.t(), [Flicker.Facet.t()], keyword()) ::
  {:ok, %{required(atom()) => %{required(term()) => non_neg_integer()}}}
  | {:error, term()}

Counts how many records each requested facet's values would match (Spec 021).

Drill-down semantics (ADR-014): for each counted facet, every other active facet and the free text apply, but that facet's own clauses do not. Counting against the whole query instead would make every sibling value read 0 the instant one is selected — the user's own choice erasing the information they needed.

Every read is actor:/tenant:-scoped exactly as search/2's is, so a count can never include a record the actor couldn't have read.

An :enum facet is counted across all its declared values, zeroes included, because a genuine zero and an uncounted value are different facts and only the full set lets an editor show Archived 0 rather than dropping the row. Other counted facets report only the values actually present, capped by :count_limit (default 50) — enumerating every related record to discover its zeroes is unbounded work.

facets(opts)

@spec facets(keyword()) :: [Flicker.Facet.t()]

Expands opts[:facets] into [Flicker.Facet.t()] by introspecting opts[:resource] — the facet registry (Spec 003).

Each entry in opts[:facets] is a bare atom (:status) naming an attribute, relationship, aggregate, or calculation on the resource, or a {key, overrides} pair layering :type, :path, :attribute, :aggregate, :op, or :label on top of what introspection would derive. Per the spec's type table:

  • an Ash.Type.Enum (or a plain attribute with a one_of constraint) derives type: :enum, :eq-only, :values, and :value_labels (the enum's own labels, or a humanised fallback); a value outside :values is rejected by the parser (degrades to free text) rather than reaching a filter.
  • :boolean derives type: :boolean, :eq-only.
  • a date/datetime attribute derives type: :date, operators :eq :gte :lt (relative values like 7d are the parser's concern — see Flicker.Query).
  • :integer/:decimal/:float derive numeric type, the full comparison operator set.
  • a belongs_to/has_* relationship derives a :string-typed facet (the related record's id) with :related set to %{resource: destination} — the nested-search descriptor a recursive Flicker search would run over — and :target resolved to the relationship's own filter path (the source attribute for belongs_to, [name, primary_key] for has_*).
  • an aggregate or calculation derives from its own resolved Ash type the same way an attribute would (numeric, boolean, ...).
  • anything else (plain :string, or a field introspection can't find) derives type: :string, :contains-only — free ilike, no picklist.

overrides[:path] (a relationship path ending in an attribute, e.g. [:worker, :full_name]) and overrides[:attribute] / overrides[:aggregate] (an explicit field name, when it differs from the facet key) redirect which field is introspected; overrides[:type] and overrides[:op] replace the derived :type / :default_op outright.

fetch(values, opts)

@spec fetch([term()], keyword()) :: {:ok, [Flicker.Result.t()]} | {:error, term()}

Resolves values against opts[:resource]'s primary key in a single primary_key in ^values read (ADR-003). Values that don't resolve are simply absent from the result — not an error.

search(query, opts)

@spec search(Flicker.Query.t(), keyword()) ::
  {:ok, [Flicker.Result.t()]} | {:error, term()}

Runs opts[:read_action] on opts[:resource], filtering by opts[:filter] (if any), then query.facets (Spec 003 — distinct facet keys AND, repeated instances of the same key OR, composed via Flicker.Query.to_filter/2 against this provider's own facet registry so the candidates are scoped before the text match runs), then query.text (case-insensitive substring match over opts[:search]), sorted by opts[:sort] (if any), limited to opts[:limit] and offset by opts[:offset] (default 0, via Ash.Query.offset/2 — Spec 010). Always actor/tenant-scoped (ADR-004).