PhoenixKitEcommerce.Shopify.ProductDiff (PhoenixKitEcommerce v0.5.0)

Copy Markdown View Source

Compares local products against Shopify Admin API product data.

Matching is by handle: for a catalogue-backed view-struct (metadata["_shopify"]["handle"], written by ProductSource.Catalogue.View) that handle is used directly; for a legacy product (no such metadata) matching falls back to product.slug[base_locale] == shopify_product["handle"], unchanged. A Shopify product with no local match is skipped by diff/4 — this module never creates products from there (product creation stays the CSV importer's job under the legacy source; under the catalogue source, new_product_changes/3 below surfaces those same unmatched handles as create-Changes for Shopify.Sync instead).

Compared fields: title, body_html, description, vendor, tags, status, price, compare_at_price. title/body_html/description are localized fields; only the base locale is read/compared here (writing them back is Shopify.Sync.apply_change/2's job). diff/4's opts[:only] narrows this set to a chosen list of fields — needed because some Shopify data sources (e.g. a public storefront fallback) only ever carry a subset of fields, such as price alone. Comparing an absent field against a present local value would otherwise be reported as a deletion, and applying such a change would erase real data.

body_html is normalized through HtmlToMarkdown.convert/1 BEFORE it is compared or stored as incoming — Shopify's API always returns raw HTML, but the locally stored value is Markdown (the storefront renders it through a Markdown component, and raw HTML embeds any Markdown a seller already hand-wrote instead of rendering it). Comparing raw-HTML-incoming against Markdown-current directly would report every synced product as "changed" on every single check, forever, since the two sides can never be byte-equal even when the content is identical. Converting first means both sides really are the same format, so a real no-op compares as one; description still runs HtmlText.extract_description/1 against the ORIGINAL raw body_html, unconverted — it strips tags for a plain-text summary and must not pick up Markdown syntax as literal characters.

diff/4 (and its diff/2/diff/3 arities) is pure — no network or database access — so it can be tested directly with in-memory product structs and Shopify API response maps.

Summary

Functions

The full set of fields diff/4 can compare — its opts[:only] default, and what a caller (e.g. PhoenixKitEcommerce.Shopify.Source) should pass for a source that carries every field, such as the Admin API. There is no :all sentinel accepted by opts[:only] — passing this list explicitly is the correct way to ask for "everything".

Matches shopify_products to local_products by handle and returns one Change per match that has at least one real difference. Shopify products without a matching local product are skipped.

Counts distinct local_products matched by handle to a shopify_products entry — the same matching rule diff/4 uses (product.slug[base_locale] == shopify_product["handle"]), but independent of whether the match has any actual field difference.

Shopify products with no matching local product, as create-Changes (create?: true, product_uuid: nil, shopify_product: <raw payload>, changes: %{}) — the mirror image of diff/4's own skip rule (see this module's moduledoc). Meant for the catalogue source only, where a new Shopify handle becomes a new catalogue item via PhoenixKitEcommerce.Catalogue.Writer.create_from_shopify/2; the legacy source has no create path here at all (product creation stays the CSV importer's job there), so a caller must not call this under the legacy source.

Functions

comparable_fields()

@spec comparable_fields() :: [atom()]

The full set of fields diff/4 can compare — its opts[:only] default, and what a caller (e.g. PhoenixKitEcommerce.Shopify.Source) should pass for a source that carries every field, such as the Admin API. There is no :all sentinel accepted by opts[:only] — passing this list explicitly is the correct way to ask for "everything".

diff(local_products, shopify_products, base_locale \\ Translations.default_language(), opts \\ [])

Matches shopify_products to local_products by handle and returns one Change per match that has at least one real difference. Shopify products without a matching local product are skipped.

base_locale defaults to Translations.default_language/0, which reads through PhoenixKit.Settings — pass it explicitly to keep a call free of database access (e.g. in tests).

opts[:only] restricts the comparison to the given list of field atoms (a subset of [:title, :body_html, :description, :vendor, :tags, :status, :price, :compare_at_price]), defaulting to all of them. Pass it when the incoming Shopify data only ever carries some fields — e.g. a public-storefront fallback source that reads price alone — so the fields it doesn't carry aren't reported (and later applied) as deletions. An unknown key in opts raises, so a typo (e.g. onlyy:) can't silently fall back to comparing every field. Every element of opts[:only] itself is validated against [:title, :body_html, :description, :vendor, :tags, :status, :price, :compare_at_price] too — a typo'd field atom there (only: [:titel]) fails the opposite way a missing :only does: instead of comparing too much, it would compare nothing and report a catalog "in sync" that was never actually checked, which is the worse failure mode for a sync tool. only: [] (compare nothing, deliberately) is not an error — every element of an empty list is vacuously valid.

base_locale must be a string. This guards against the easy mistake of passing opts as the third argument and dropping base_locale entirely (diff(local, shopify, only: [:price])) — without the guard that silently matches nothing and returns [], instead of raising.

matched_count(local_products, shopify_products, base_locale \\ Translations.default_language())

@spec matched_count([PhoenixKitEcommerce.Product.t()], [map()], String.t()) ::
  non_neg_integer()

Counts distinct local_products matched by handle to a shopify_products entry — the same matching rule diff/4 uses (product.slug[base_locale] == shopify_product["handle"]), but independent of whether the match has any actual field difference.

This is "how much of the Shopify catalog a sync can even see": every local product with no matching Shopify handle is invisible to diff/4 regardless of :only (see this module's moduledoc — a Shopify product with no local match is skipped, and the reverse is equally true: a local product with no Shopify-side handle never reaches build_change/4 at all). Reuses diff/4's own index_by_handle/2, so this can never drift from what diff/4 actually matches.

base_locale defaults to Translations.default_language/0, same as diff/4 — pass it explicitly to keep a call free of that default's database access.

new_product_changes(local_products, shopify_products, base_locale \\ Translations.default_language())

Shopify products with no matching local product, as create-Changes (create?: true, product_uuid: nil, shopify_product: <raw payload>, changes: %{}) — the mirror image of diff/4's own skip rule (see this module's moduledoc). Meant for the catalogue source only, where a new Shopify handle becomes a new catalogue item via PhoenixKitEcommerce.Catalogue.Writer.create_from_shopify/2; the legacy source has no create path here at all (product creation stays the CSV importer's job there), so a caller must not call this under the legacy source.

base_locale defaults to Translations.default_language/0, same as diff/4 — pass it explicitly to keep a call free of that default's database access.