PhoenixKitEcommerce.Catalogue.ValueResolver (PhoenixKitEcommerce v0.5.0)

Copy Markdown View Source

Resolves a raw label — text as it arrives from an external source such as a Shopify option value ("Small", "Rouge") — to the value SLUG a catalogue attribute set already uses for that same choice, creating a new draft value when the set has none matching.

Built for Block 7 (the Shopify sync writing catalogue attribute-set selections instead of raw strings); nothing in this fork calls it yet.

Lookup order

  1. Slug match: PhoenixKit.Utils.Slug.slugify(raw_label) against every value's stored :slug in the set (draft values included — only archived ones are excluded by AttributeSets.list_values/2, so a value this resolver created as draft on a previous call is found again rather than duplicated).
  2. Exact-label match: the same whitespace-collapsed label against each value's :title.
  3. Miss: a new value is created.

Why Slug.slugify/1 computes the slug handed to create_value/3

AttributeSets.create_value/3 derives its own slug from the label only when no explicit :slug is given, via a hand-rolled ASCII-only fallback in that module with no transliteration — a non-Latin label there slugifies to "" and gets a random suffix instead of a stable, readable slug. Passing the already-transliterated PhoenixKit.Utils.Slug.slugify/1 result as the explicit :slug sidesteps that, AND keeps the value locatable by the same slug next time this resolver runs against the same label — a random suffix would not be reproducible across calls.

Why the new value is created draft, and how

create_value/3 has no :status option — every value it creates is hardcoded "published". EntityData.bulk_update_status/3 is the only bulk alternative, and it skips per-record changeset validation and the per-record activity log — the wrong trade for one new row. The shortest correct path read from the API is: create (published), then PhoenixKitEntities.EntityData.update/3 with %{status: "draft"}:status is a cast-and-validate_inclusion/3-checked field on EntityData.changeset/2, so this is a normal, validated write, not a raw SQL patch. (There is no PhoenixKitEntities.update_entity_data/2 — that name does not exist on the top-level module; the real API is the EntityData.update/3 used here.) Both writes run inside one PhoenixKit.RepoHelper.repo().transaction/1 — a failed status update rolls the just-created (published) value back too, rather than leaving a published, un-approved value behind with the caller seeing only an error tuple.

Storefront facets and pickers already exclude non-published values (Query.attribute_set_counts/2 joins on entity_data.status == "published"), so a draft value this resolver creates does not appear on the storefront until an admin publishes it.

Set lookup never creates

Sets are admin-managed blueprints (AttributeSets.create_set/2, a deliberate admin action) — set_slug must resolve to an EXISTING set or this returns {:error, :set_not_found}; nothing here ever creates a set.

Summary

Functions

Resolves raw_label to a value slug within the set named set_slug (bare or "catalogue_set_"-prefixed — the same lookup Query.filter_by_metadata/2 uses). opts is forwarded to AttributeSets.list_values/2 and create_value/3 (e.g. :actor_uuid, :mode for the activity log).

Same as resolve/3, but for MULTIPLE labels against the SAME set — resolve/3 in a loop runs AttributeSets.list_sets/0 (a full entities read, filtered in Elixir) and list_values/2 once PER LABEL, an N+1 by construction for the exact caller this module was built for (Block 7's Shopify sync, one label per variant option per product). This resolves the set and reads its values once and reuses both across every label — a :created value from an earlier label in the same call is visible to a later label in the same call (a value read once at the top would go stale the moment ANY label in the batch creates one), so two labels needing the same NEW value in one call create it only once.

Types

result()

@type result() ::
  {:ok, String.t()} | {:created, String.t()} | {:error, :set_not_found | term()}

Functions

resolve(set_slug, raw_label, opts \\ [])

@spec resolve(String.t(), String.t(), keyword()) :: result()

Resolves raw_label to a value slug within the set named set_slug (bare or "catalogue_set_"-prefixed — the same lookup Query.filter_by_metadata/2 uses). opts is forwarded to AttributeSets.list_values/2 and create_value/3 (e.g. :actor_uuid, :mode for the activity log).

Returns {:ok, slug} for an existing match, {:created, slug} when a new draft value had to be made, {:error, :set_not_found} when set_slug doesn't resolve to a set (or whatever error create_value/3 / the follow-up status update returns, on the rare write failure).

resolve_many(set_slug, raw_labels, opts \\ [])

@spec resolve_many(String.t(), [String.t()], keyword()) :: %{
  required(String.t()) => result()
}

Same as resolve/3, but for MULTIPLE labels against the SAME set — resolve/3 in a loop runs AttributeSets.list_sets/0 (a full entities read, filtered in Elixir) and list_values/2 once PER LABEL, an N+1 by construction for the exact caller this module was built for (Block 7's Shopify sync, one label per variant option per product). This resolves the set and reads its values once and reuses both across every label — a :created value from an earlier label in the same call is visible to a later label in the same call (a value read once at the top would go stale the moment ANY label in the batch creates one), so two labels needing the same NEW value in one call create it only once.

Returns %{raw_label => resolve/3's return}, one entry per DISTINCT raw label (duplicates in raw_labels collapse to a single lookup).