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
@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".
@spec diff([PhoenixKitEcommerce.Product.t()], [map()], String.t(), keyword()) :: [ PhoenixKitEcommerce.Shopify.ProductDiff.Change.t() ]
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.
@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.
@spec new_product_changes([PhoenixKitEcommerce.Product.t()], [map()], String.t()) :: [ PhoenixKitEcommerce.Shopify.ProductDiff.Change.t() ]
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.