Attribute groups — reusable, translatable sets of product characteristics.
A group ("Idea doors") owns attributes ("Color", "Trim"), each attribute
owns ordered values ("White", "Oak"); an item is linked to one group
through phoenix_kit_cat_item_attribute_groups and inherits everything
the group defines. This is an evolution of the hand-typed per-item
metadata (item.data["meta"]), which stays untouched and is surfaced
read/editable by the item form's legacy collapse.
Identity and translations
key slugs (auto-generated from the primary-language name via
PhoenixKit.Utils.Slug, immutable after creation) plus row UUIDs are the
durable identity — future exclusion rules and parent-app order lines
reference them, so editing a translation never changes what old data
means. Display names ride the module's multilang data JSONB convention
(primary language in the name/value columns, other languages in
data); resolve them with resolved_group/2.
Deletion vs archive
All three definition levels carry status ("active" / "archived").
Archive is the path for anything in use: the DB RESTRICTs deleting a
group any item references, and delete_attribute_group/2 performs the
values → attributes → group cascade explicitly in one transaction
(mirroring permanently_delete_catalogue/2's gate-then-cascade shape)
rather than trusting a silent DB cascade.
Downstream contract
Order lines in the parent app that record a chosen value must snapshot the resolved labels and keys at order time — the UUID reference alone is identity, not history.
Public surface is re-exported from PhoenixKitCatalogue.Catalogue via
defdelegate.
Summary
Functions
How many items are assigned to each of the given groups — one grouped
query. Returns %{group_uuid => count}; drives the "in use" gate and
the list page's usage column.
Per-group attribute counts for the groups list — one grouped query,
active attributes only. Returns %{group_uuid => count}.
Returns a changeset for tracking attribute-group form changes.
Adds an attribute to a group. The stable key slug is generated from
the given name (deduped within the group); position appends at the end.
Creates an attribute group.
Adds a value to an attribute. The stable key slug is generated from
the display text (deduped within the attribute); position appends at the
end; the attribute's first value becomes the default automatically.
Deletes an attribute and its values in one transaction.
Hard-deletes a group with an explicit values → attributes → group cascade in one transaction.
Deletes a value. If it was the default, the lowest-position remaining
active value is promoted so a multi attribute never silently loses
its default.
Fetches an attribute by UUID (with its group). Returns nil if not found.
Fetches an attribute group by UUID. Returns nil if not found.
Fetches a group with ALL its attributes and values preloaded in position
order, archived rows included — the group editor's working set. Consumer
paths (item preview, product card) want resolved_group/2 instead.
Fetches a value by UUID (with its attribute). Returns nil if not found.
The item's current assignment (or nil) — one indexed lookup.
Batch map of %{item_uuid => attribute_group_uuid} for the given items —
one indexed query; drives the list/card indicator chips with no per-row
lookups.
Lists attribute groups ordered by position, then name.
Persists a manual ordering of an attribute's values (same contract as reorder_attributes/2).
Persists a manual ordering of a group's attributes. UUIDs not in the list keep their position; unknown UUIDs are dropped BEFORE any writes — the client list is forgeable, so the write count is bounded by the group's real row count, never by payload length (panel finding).
The programmatic read surface: a group resolved for display in lang —
active attributes in position order, each with its active values in
position order, names/labels translated with primary-language fallback.
Makes a value its attribute's default. Unset-then-set inside one
transaction (the partial unique index allows at most one default);
fails with {:error, :not_found} when the value vanished concurrently
and {:error, :conflict} when two flips race on the index.
Sets (or clears, with nil) the item's attribute group.
Updates an attribute (name, translations, kind, status, position). key is immutable.
Updates an attribute group (name, translations, status, position).
Updates a value's display text / translations / status. key is immutable.
Functions
@spec assignment_counts([Ecto.UUID.t()]) :: %{ required(Ecto.UUID.t()) => non_neg_integer() }
How many items are assigned to each of the given groups — one grouped
query. Returns %{group_uuid => count}; drives the "in use" gate and
the list page's usage column.
@spec attribute_counts([Ecto.UUID.t()]) :: %{ required(Ecto.UUID.t()) => non_neg_integer() }
Per-group attribute counts for the groups list — one grouped query,
active attributes only. Returns %{group_uuid => count}.
@spec change_attribute_group(PhoenixKitCatalogue.Schemas.AttributeGroup.t(), map()) :: Ecto.Changeset.t()
Returns a changeset for tracking attribute-group form changes.
@spec create_attribute( PhoenixKitCatalogue.Schemas.AttributeGroup.t(), map(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.Attribute.t()} | {:error, Ecto.Changeset.t()}
Adds an attribute to a group. The stable key slug is generated from
the given name (deduped within the group); position appends at the end.
@spec create_attribute_group( map(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeGroup.t()} | {:error, Ecto.Changeset.t()}
Creates an attribute group.
@spec create_attribute_value(PhoenixKitCatalogue.Schemas.Attribute.t(), map()) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()} | {:error, Ecto.Changeset.t()}
Adds a value to an attribute. The stable key slug is generated from
the display text (deduped within the attribute); position appends at the
end; the attribute's first value becomes the default automatically.
@spec delete_attribute( PhoenixKitCatalogue.Schemas.Attribute.t(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.Attribute.t()} | {:error, term()}
Deletes an attribute and its values in one transaction.
@spec delete_attribute_group( PhoenixKitCatalogue.Schemas.AttributeGroup.t(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeGroup.t()} | {:error, :in_use | Ecto.Changeset.t()}
Hard-deletes a group with an explicit values → attributes → group cascade in one transaction.
Gated: returns {:error, :in_use} when any item is assigned to the
group (the assignment FK would RESTRICT anyway — the gate turns the
constraint error into a domain answer). Archive is the path for groups
in use.
@spec delete_attribute_value(PhoenixKitCatalogue.Schemas.AttributeValue.t()) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()} | {:error, term()}
Deletes a value. If it was the default, the lowest-position remaining
active value is promoted so a multi attribute never silently loses
its default.
@spec get_attribute(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Attribute.t() | nil
Fetches an attribute by UUID (with its group). Returns nil if not found.
@spec get_attribute_group(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.AttributeGroup.t() | nil
Fetches an attribute group by UUID. Returns nil if not found.
@spec get_attribute_group_full(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.AttributeGroup.t() | nil
Fetches a group with ALL its attributes and values preloaded in position
order, archived rows included — the group editor's working set. Consumer
paths (item preview, product card) want resolved_group/2 instead.
@spec get_attribute_value(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.AttributeValue.t() | nil
Fetches a value by UUID (with its attribute). Returns nil if not found.
@spec get_item_attribute_group_uuid(Ecto.UUID.t()) :: Ecto.UUID.t() | nil
The item's current assignment (or nil) — one indexed lookup.
@spec item_attribute_group_map([Ecto.UUID.t()]) :: %{ required(Ecto.UUID.t()) => Ecto.UUID.t() }
Batch map of %{item_uuid => attribute_group_uuid} for the given items —
one indexed query; drives the list/card indicator chips with no per-row
lookups.
@spec list_attribute_groups(keyword()) :: [ PhoenixKitCatalogue.Schemas.AttributeGroup.t() ]
Lists attribute groups ordered by position, then name.
Options
:status— filter by status ("active"/"archived"); nil = all.
@spec reorder_attribute_values(PhoenixKitCatalogue.Schemas.Attribute.t(), [ Ecto.UUID.t() ]) :: :ok
Persists a manual ordering of an attribute's values (same contract as reorder_attributes/2).
@spec reorder_attributes(PhoenixKitCatalogue.Schemas.AttributeGroup.t(), [ Ecto.UUID.t() ]) :: :ok
Persists a manual ordering of a group's attributes. UUIDs not in the list keep their position; unknown UUIDs are dropped BEFORE any writes — the client list is forgeable, so the write count is bounded by the group's real row count, never by payload length (panel finding).
@spec resolved_group(Ecto.UUID.t() | nil, String.t()) :: map() | nil
The programmatic read surface: a group resolved for display in lang —
active attributes in position order, each with its active values in
position order, names/labels translated with primary-language fallback.
Returns nil when the group doesn't exist. Shape:
%{
uuid: ..., key-less group: name: "Idea doors",
attributes: [
%{uuid: ..., key: "color", name: "Цвет", kind: "multi",
values: [%{uuid: ..., key: "oak", value: "Дуб", default?: true}, ...]},
...
]
}This is what the item form preview, the product card, and (later) the parent app's order-line picker all consume — module boundaries stay at the context, not at raw table access.
@spec set_default_value(PhoenixKitCatalogue.Schemas.AttributeValue.t()) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()} | {:error, term()}
Makes a value its attribute's default. Unset-then-set inside one
transaction (the partial unique index allows at most one default);
fails with {:error, :not_found} when the value vanished concurrently
and {:error, :conflict} when two flips race on the index.
@spec set_item_attribute_group( PhoenixKitCatalogue.Schemas.Item.t(), Ecto.UUID.t() | nil, keyword() ) :: {:ok, :assigned | :cleared | :unchanged} | {:error, :invalid_group | Ecto.Changeset.t()}
Sets (or clears, with nil) the item's attribute group.
Validates the group exists and is active — except that keeping the
item's CURRENT group is always allowed even when that group has been
archived since (the stale-select rule: an archived assignment renders,
it just can't be newly chosen). Returns {:error, :invalid_group} for
anything else.
@spec update_attribute(PhoenixKitCatalogue.Schemas.Attribute.t(), map()) :: {:ok, PhoenixKitCatalogue.Schemas.Attribute.t()} | {:error, Ecto.Changeset.t()}
Updates an attribute (name, translations, kind, status, position). key is immutable.
@spec update_attribute_group( PhoenixKitCatalogue.Schemas.AttributeGroup.t(), map(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeGroup.t()} | {:error, Ecto.Changeset.t()}
Updates an attribute group (name, translations, status, position).
@spec update_attribute_value(PhoenixKitCatalogue.Schemas.AttributeValue.t(), map()) :: {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()} | {:error, Ecto.Changeset.t()}
Updates a value's display text / translations / status. key is immutable.