PhoenixKitCatalogue.Catalogue.AttributeSets (PhoenixKitCatalogue v0.25.0)

Copy Markdown View Source

Attribute SETS — the 2026-08-18 rework of the group/attribute system.

A set is one dimension from one vendor ("Ikea colors"), stored as a MANAGED entities blueprint (created only through this module, hidden from the generic entities admin); its data records are the values. Items attach any number of sets through the catalogue-owned phoenix_kit_cat_item_attribute_sets join (V177).

The blueprint contract

name:      "catalogue_set_<slug>"          (immutable identity)
settings:  "managed_by"  => "catalogue"
           "locked_keys" => ["kind", "default_value_slug"]
           "catalogue"   => %{"kind" => "fixed" | "multi",
                              "default_value_slug" => slug | nil}
records:   slug = the value's stable key, title = display text,
           position = order, data = extras (per-set fields)

Everything else on the blueprint (display name, translations, fields_definition extras like "price per liter") is freely editable. contract/1 validates the shape on every resolve; a broken contract is surfaced ({:error, :contract_broken}), never guessed around.

Enablement

Requires the entities module (PhoenixKitEntities.enabled?/0). Every WRITE returns {:error, :entities_disabled} when it is off — same loud-failure doctrine as the :catalogue_pdf queue guard. Reads degrade quietly instead ([], nil, %{}, 0): UI callers render empty rather than crash during a feature toggle.

Public surface re-exported from PhoenixKitCatalogue.Catalogue.

Summary

Functions

Adds an extra field to the set's blueprint (:label required, :type one of extra_field_types/0; select additionally needs :options, a non-empty list). Every value can then carry data for it. The key is derived from the label and is stable.

Attaches a set to an item (appends; no-op when already attached).

How many items attach each of the given sets: %{set_uuid => count}.

Migrates any remaining legacy groups into sets, silently and safely — there is no legacy UI once sets are live ("it should just migrate", boss direction 2026-08-18), so this runs from the supervision-tree startup task and again from the attributes page as a backstop (boot can race the repo/settings, and entities can be enabled at runtime).

Registers the catalogue's blueprint delete guard with entities. Ships as a supervision child via PhoenixKitCatalogue.children/0, so it runs once per boot; deleting a set with item attachments is refused at the entities write path.

Validates a set blueprint's catalogue contract. Returns {:ok, %{kind: atom, default: slug | nil}} or {:error, :contract_broken} — never a guessed fallback.

Provisions a new set: a managed blueprint from the locked template.

Adds a value to a set. attrs: :label (required), :slug (derived from label when absent), :extras (map merged into the record's data — cast against the blueprint's fields the same way update_value/4 casts them).

The set's default value slug, or nil. See kind/1.

Deletes a set. Refused ({:error, :set_in_use}) while any item attaches it — the same guard entities consults on its own delete path.

Deletes a value record. When the value is the set's default, the default is cleared first so the contract never points at a ghost.

Detaches a set from an item (no-op when not attached).

True when the sets feature is live: the entities module is enabled AND its package carries the Managed API (entities > 0.4.0) — on an older package the whole feature degrades to :entities_disabled rather than crashing on missing functions. UI surfaces branch on this to decide sets-vs-legacy rendering.

Extra-field types the set editor offers (a curated entities subset).

The attribute filter's options for one catalogue: every set attached to an item in it, with that set's values.

Fetches one set by blueprint uuid (nil when missing/not a set).

Fetches one value record, scoped to the set (nil when foreign/missing).

The set's kind string ("fixed"/"multi", tolerant default "multi"). Public so UI layers read the contract through one accessor instead of destructuring settings["catalogue"] — the strict validating read stays contract/1.

One page of the items attached to a set, name-ordered — the set detail page's listing. Each row is %{item: %Item{}, selected_slugs: [...]}; the slugs are the RAW attachment selection — callers ghost-filter them against the set's current values with valid_selection/2. Deleted items are excluded.

The item's attachments in order.

Lists the catalogue's sets (managed blueprints), locale-resolved.

Lists a set's values in display order, locale-resolved.

Values for MANY sets at once: %{set_uuid => [value]}, archived excluded.

Migrates the legacy group→attribute→value data into sets

Removes attachments whose set blueprint no longer exists (called by AttributeSets.OrphanPruner off entities PubSub delete events).

Removes an extra field from the blueprint. Existing per-value data for the key is left in place (harmless, invisible) — same doctrine as entities' own field removal.

Reorders an item's attachments to the given set_uuid order. No-op (no writes, no activity row) when the order already matches — this runs on every item save.

Reorders a set's values to the given record-uuid order.

Resolves the attached sets for many items in one batched pass: one attachment query + one value listing per DISTINCT set (values are shared across items, so a 50-item page with 6 sets is 7 queries).

Resolves ONE set to the v2 per-set shape (%{uuid, key, name, kind, default, values, fields}), or nil when the set is missing or its contract is broken. No attachment context, so no :selected key — that exists only on resolve_for_items/2's per-item sets. Powers the item form's attach-preview; the batched item reads go through resolve_for_items/2.

True when any item attaches the set (drives the delete guard).

Stores the per-attachment value selection (selected_value_slugs in the join row's reserved data) — the boss's two modes: ONE slug says "this exact object is Red", several say "this object comes in these options", empty clears the statement. Unknown slugs are dropped against the set's current values; {:error, :not_attached} when the item doesn't attach the set.

Of the given sets, which have at least one VALUE whose label matches term? Returns a list of set uuids — the listing search uses it to find a set by what is IN it ("oak" finds the color set), not only by its own name.

Updates an existing extra field: :label renames the display text (the key — referenced by stored per-value data — never changes), :options replaces a select field's option list (non-empty required). The type is immutable after creation: stored values were cast for it.

Updates a set's unlocked surface: :name (display), :description, :kind, :default_value_slug. Kind/default ride the owner bypass — they are locked against GENERIC writes, not against this module.

Updates a value: :label rewrites the display text (the slug — the stable key — never changes), :extras merges into the record data.

Filters stored selection slugs against a resolved set's CURRENT values — THE single implementation of the ghost rule, shared with every hydration path (the item form stages selections off raw attachment rows).

Value counts for many sets at once: %{set_uuid => count}, matching list_values/2's semantics (archived and trashed excluded). One grouped query — the viewer must not COUNT per row.

How many rows each attribute VALUE would still match, given the filter already applied — the numbers beside the filter's checkboxes, and what lets a value that leads nowhere be disabled instead of offered (Max, 2026-08-28).

Functions

add_extra_field(set, attrs, opts \\ [])

@spec add_extra_field(struct(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Adds an extra field to the set's blueprint (:label required, :type one of extra_field_types/0; select additionally needs :options, a non-empty list). Every value can then carry data for it. The key is derived from the label and is stable.

attach_set(item_uuid, set_uuid, opts \\ [])

@spec attach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.ItemAttributeSet.t()} | {:error, term()}

Attaches a set to an item (appends; no-op when already attached).

Runs under the per-set advisory lock shared with delete_set/2 — without it, an attach racing a delete could commit after the guard's set_attached? check read false, leaving an instant orphan row (panel finding, 2026-08-18 review).

attachment_counts(set_uuids)

@spec attachment_counts([Ecto.UUID.t()]) :: %{
  optional(Ecto.UUID.t()) => non_neg_integer()
}

How many items attach each of the given sets: %{set_uuid => count}.

auto_migrate_legacy()

@spec auto_migrate_legacy() :: :ok

Migrates any remaining legacy groups into sets, silently and safely — there is no legacy UI once sets are live ("it should just migrate", boss direction 2026-08-18), so this runs from the supervision-tree startup task and again from the attributes page as a backstop (boot can race the repo/settings, and entities can be enabled at runtime).

Never raises: any failure is logged and swallowed — a broken migration must not take down boot or an admin page. Idempotent by way of migrate_groups_to_sets/1.

child_spec(opts)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Registers the catalogue's blueprint delete guard with entities. Ships as a supervision child via PhoenixKitCatalogue.children/0, so it runs once per boot; deleting a set with item attachments is refused at the entities write path.

contract(set)

@spec contract(struct()) :: {:ok, map()} | {:error, :contract_broken}

Validates a set blueprint's catalogue contract. Returns {:ok, %{kind: atom, default: slug | nil}} or {:error, :contract_broken} — never a guessed fallback.

count_attached_items(set_uuid, opts \\ [])

@spec count_attached_items(
  Ecto.UUID.t(),
  keyword()
) :: non_neg_integer()

Total match count for list_attached_items/2 (same filters).

create_set(attrs, opts \\ [])

@spec create_set(
  map(),
  keyword()
) :: {:ok, struct()} | {:error, term()}

Provisions a new set: a managed blueprint from the locked template.

attrs: :name (display, required), :slug (optional — derived from the name when absent), :kind ("fixed"/"multi", default "multi"), :description.

create_value(set, attrs, opts \\ [])

@spec create_value(struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}

Adds a value to a set. attrs: :label (required), :slug (derived from label when absent), :extras (map merged into the record's data — cast against the blueprint's fields the same way update_value/4 casts them).

default_value_slug(set)

@spec default_value_slug(struct()) :: String.t() | nil

The set's default value slug, or nil. See kind/1.

delete_set(set, opts \\ [])

@spec delete_set(struct(), keyword()) :: {:ok, struct()} | {:error, term()}

Deletes a set. Refused ({:error, :set_in_use}) while any item attaches it — the same guard entities consults on its own delete path.

delete_value(set, value, opts \\ [])

@spec delete_value(struct(), struct(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Deletes a value record. When the value is the set's default, the default is cleared first so the contract never points at a ghost.

detach_set(item_uuid, set_uuid, opts \\ [])

@spec detach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: :ok

Detaches a set from an item (no-op when not attached).

enabled?()

@spec enabled?() :: boolean()

True when the sets feature is live: the entities module is enabled AND its package carries the Managed API (entities > 0.4.0) — on an older package the whole feature degrades to :entities_disabled rather than crashing on missing functions. UI surfaces branch on this to decide sets-vs-legacy rendering.

extra_field_types()

@spec extra_field_types() :: [String.t()]

Extra-field types the set editor offers (a curated entities subset).

filter_options(catalogue_uuid, opts \\ [])

@spec filter_options(
  Ecto.UUID.t() | :all,
  keyword()
) :: [map()]

The attribute filter's options for one catalogue: every set attached to an item in it, with that set's values.

Only sets actually in use appear — a filter offering "Colour" for a catalogue of screws is noise. Values come from the set itself rather than from what is currently selected, so picking one that matches nothing yet returns an honest empty list instead of hiding the option.

Shape: [%{uuid:, name:, values: [%{slug:, title:}]}], named for what the UI renders.

get_set(uuid, opts \\ [])

@spec get_set(
  Ecto.UUID.t(),
  keyword()
) :: struct() | nil

Fetches one set by blueprint uuid (nil when missing/not a set).

get_value(set, value_uuid)

@spec get_value(
  struct(),
  Ecto.UUID.t()
) :: struct() | nil

Fetches one value record, scoped to the set (nil when foreign/missing).

kind(set)

@spec kind(struct()) :: String.t()

The set's kind string ("fixed"/"multi", tolerant default "multi"). Public so UI layers read the contract through one accessor instead of destructuring settings["catalogue"] — the strict validating read stays contract/1.

list_attached_items(set_uuid, opts \\ [])

@spec list_attached_items(
  Ecto.UUID.t(),
  keyword()
) :: [map()]

One page of the items attached to a set, name-ordered — the set detail page's listing. Each row is %{item: %Item{}, selected_slugs: [...]}; the slugs are the RAW attachment selection — callers ghost-filter them against the set's current values with valid_selection/2. Deleted items are excluded.

Options: :search (trimmed, matched on item name), :limit (default 25), :offset (default 0).

list_attachments(item_uuid)

@spec list_attachments(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.ItemAttributeSet.t()
]

The item's attachments in order.

list_sets(opts \\ [])

@spec list_sets(keyword()) :: [struct()]

Lists the catalogue's sets (managed blueprints), locale-resolved.

list_values(set_or_uuid, opts \\ [])

@spec list_values(
  struct() | Ecto.UUID.t(),
  keyword()
) :: [struct()]

Lists a set's values in display order, locale-resolved.

list_values_for(set_uuids, opts \\ [])

@spec list_values_for(
  [Ecto.UUID.t()],
  keyword()
) :: %{optional(Ecto.UUID.t()) => [struct()]}

Values for MANY sets at once: %{set_uuid => [value]}, archived excluded.

The batched twin of list_values/2. A listing that shows a preview of each set's values must not call the singular form per row — the sets listing did, and paged 25 at a time, so opening the Attributes tab cost 25 queries and repeated them on every attribute or item broadcast.

:limit is PER SET. The batched entities API filters status in SQL, which is what makes that limit mean what it says; the fallback for an older entities pin cannot, so it over-fetches and trims — asking for 5 and getting 3 reads as "there are only 3".

migrate_groups_to_sets(opts \\ [])

@spec migrate_groups_to_sets(keyword()) :: {:ok, map()} | {:error, term()}

Migrates the legacy group→attribute→value data into sets:

  • each (group, attribute) pair → one set blueprint, slug catalogue_set_<group>_<attr-key> (display "<Group> — <Attr>");
  • attribute values → records, slug = the old value key (stable, so existing order-line picks keep resolving), old is_default → the set's default_value_slug;
  • every item's single group assignment explodes into one attachment per attribute of that group, in attribute order.

Idempotent: an existing blueprint with the target slug is reused (its values/attachments are topped up, never duplicated), so re-running after a partial failure is safe. Old tables are left untouched (read-only by convention; dropped by a later core migration after cutover). Returns {:ok, %{sets: n, values: n, attachments: n}}.

prune_orphan_attachments(set_uuid)

@spec prune_orphan_attachments(Ecto.UUID.t()) :: non_neg_integer()

Removes attachments whose set blueprint no longer exists (called by AttributeSets.OrphanPruner off entities PubSub delete events).

Guarded on enablement: with entities disabled, get_set/1 returns nil for EVERY uuid — without the guard a stray call during a feature toggle would read that as "blueprint deleted" and destroy valid attachments (panel finding, 2026-08-18 review).

remove_extra_field(set, key, opts \\ [])

@spec remove_extra_field(struct(), String.t(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Removes an extra field from the blueprint. Existing per-value data for the key is left in place (harmless, invisible) — same doctrine as entities' own field removal.

reorder_attachments(item_uuid, set_uuids, opts \\ [])

@spec reorder_attachments(Ecto.UUID.t(), [Ecto.UUID.t()], keyword()) ::
  :ok | {:error, term()}

Reorders an item's attachments to the given set_uuid order. No-op (no writes, no activity row) when the order already matches — this runs on every item save.

reorder_values(set, ordered_uuids, opts \\ [])

@spec reorder_values(struct(), [Ecto.UUID.t()], keyword()) :: :ok | {:error, term()}

Reorders a set's values to the given record-uuid order.

resolve_for_item(item_uuid, opts \\ [])

@spec resolve_for_item(
  Ecto.UUID.t(),
  keyword()
) :: map()

Single-item convenience over resolve_for_items/2.

resolve_for_items(item_uuids, opts \\ [])

@spec resolve_for_items(
  [Ecto.UUID.t()],
  keyword()
) :: %{optional(Ecto.UUID.t()) => map()}

Resolves the attached sets for many items in one batched pass: one attachment query + one value listing per DISTINCT set (values are shared across items, so a 50-item page with 6 sets is 7 queries).

Returns %{item_uuid => resolved} where resolved is the v2 shape:

%{schema_version: 2,
  sets: [%{uuid, key, name, kind, default,
           values:   [%{key, label, extras}],
           fields:   [%{key, label, type}],
           selected: [slug]}]}

:fields mirrors the blueprint's extra-field definitions (what each value's extras keys mean); :selected is the per-ATTACHMENT value selection, already intersected against current values (ghost slugs degrade out). :selected exists ONLY on this batched read — resolve_set/2 resolves a bare set with no attachment context and carries no :selected key.

Sets with a broken contract are skipped with a warning — a tampered blueprint must not take item pages down, but it must not render guessed data either.

resolve_set(set_uuid, opts \\ [])

@spec resolve_set(
  Ecto.UUID.t(),
  keyword()
) :: map() | nil

Resolves ONE set to the v2 per-set shape (%{uuid, key, name, kind, default, values, fields}), or nil when the set is missing or its contract is broken. No attachment context, so no :selected key — that exists only on resolve_for_items/2's per-item sets. Powers the item form's attach-preview; the batched item reads go through resolve_for_items/2.

set_attached?(set_uuid)

@spec set_attached?(Ecto.UUID.t()) :: boolean()

True when any item attaches the set (drives the delete guard).

set_attachment_selection(item_uuid, set_uuid, slugs, opts \\ [])

@spec set_attachment_selection(Ecto.UUID.t(), Ecto.UUID.t(), [String.t()], keyword()) ::
  :ok | {:error, term()}

Stores the per-attachment value selection (selected_value_slugs in the join row's reserved data) — the boss's two modes: ONE slug says "this exact object is Red", several say "this object comes in these options", empty clears the statement. Unknown slugs are dropped against the set's current values; {:error, :not_attached} when the item doesn't attach the set.

set_uuids_matching_value(set_uuids, term)

@spec set_uuids_matching_value([Ecto.UUID.t()], String.t()) :: [Ecto.UUID.t()]

Of the given sets, which have at least one VALUE whose label matches term? Returns a list of set uuids — the listing search uses it to find a set by what is IN it ("oak" finds the color set), not only by its own name.

One batched query where the entities pin carries the API; the fallback is the older global title search, intersected here. Archived values are excluded, matching list_values/2.

update_extra_field(set, key, attrs, opts \\ [])

@spec update_extra_field(struct(), String.t(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Updates an existing extra field: :label renames the display text (the key — referenced by stored per-value data — never changes), :options replaces a select field's option list (non-empty required). The type is immutable after creation: stored values were cast for it.

update_set(set, attrs, opts \\ [])

@spec update_set(struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}

Updates a set's unlocked surface: :name (display), :description, :kind, :default_value_slug. Kind/default ride the owner bypass — they are locked against GENERIC writes, not against this module.

update_value(set, value, attrs, opts \\ [])

@spec update_value(struct(), struct(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Updates a value: :label rewrites the display text (the slug — the stable key — never changes), :extras merges into the record data.

Extras are cast per field type through the entities pipeline (FormBuilder.cast_field/2): raw form strings coerce ("12.5" → 12.5, "" clears), invalid content returns {:error, :invalid_value} and unknown keys {:error, :unknown_field} — never a silent junk write.

valid_selection(slugs, arg2)

@spec valid_selection(term(), map() | nil) :: [String.t()]

Filters stored selection slugs against a resolved set's CURRENT values — THE single implementation of the ghost rule, shared with every hydration path (the item form stages selections off raw attachment rows).

The per-attachment selection is the boss's two modes (2026-08-19): one slug = "this exact object is Red", several = "this object comes in Red/Blue/Yellow", empty = no statement, the whole set applies. The count IS the mode — nothing else is tracked. A value deleted after being ticked must not ghost through reads: unknown slugs drop out, and a fully-ghosted selection degrades to [] ("whole set applies"), never to a vanished or mode-flipped set.

value_counts(set_uuids)

@spec value_counts([Ecto.UUID.t()]) :: %{optional(Ecto.UUID.t()) => non_neg_integer()}

Value counts for many sets at once: %{set_uuid => count}, matching list_values/2's semantics (archived and trashed excluded). One grouped query — the viewer must not COUNT per row.

value_match_counts(opts \\ [])

@spec value_match_counts(keyword()) :: %{optional(String.t()) => non_neg_integer()}

How many rows each attribute VALUE would still match, given the filter already applied — the numbers beside the filter's checkboxes, and what lets a value that leads nowhere be disabled instead of offered (Max, 2026-08-28).

Conditioned on the CURRENT selection, so once Blue is on, Oak shows how many blue oak items there are. A dead combination is therefore visible as a 0 before it is picked, rather than as an empty list afterwards.

Options: :catalogue_uuid, :catalogue_uuids, :category_uuids, :statuses, :search, :value_slugs (the selection to condition on) and :count:items (default) or :catalogues, which counts distinct catalogues for the index's version of the filter.

Every scope the LISTING is under has to be passed, or the promise breaks the other way: a value offered as live because something matches it SOMEWHERE, while the page the user is on has none of it, is exactly the empty list this exists to prevent. :search narrows by the same text predicate the item search uses.

One grouped query: the selection slugs are unnested from the attachment's JSONB, so a page with fifteen values still asks once.