Plugin: 7.2.0 (same code in 8.1.0) · Craft: 5.10.14
Summary
ProcessBulkOperationData::processItem() saves the product element as soon as it reads the Product line of the bulk-operation JSONL. The product's Metafield lines come after it and haven't been stored yet, so a uriFormat that references metafields renders empty and the product is saved with uri = NULL. Nothing saves the product again once the metafields are stored.
For per-product syncs (webhooks → syncProductByShopifyId()) this breaks existing products too: clearData is the product GID, so before() deletes the product's stored metafields before the element is saved. Every product that changes in Shopify loses its URL.
Steps to reproduce
- Set Product URI Format to
{metafields.canonical_uri} (any metafield will do) and make sure the metafield is populated in Shopify.
- Run a full sync and resave the products, then confirm they have URIs.
- Edit one product in Shopify so the
products/update webhook fires, and let the queue run.
- That product's
elements_sites.uri is now NULL and product.url is null. Its canonical_uri metafield row is present in shopify_data.
- Resaving the product restores the URI.
Cause
src/jobs/ProcessBulkOperationData.php:
$record->save();
// Process the data based on the type
if ($record->type === 'Product') {
Plugin::getInstance()->getProducts()->createOrUpdateProduct($item);
}
Shopify writes child objects as separate JSONL lines carrying a __parentId, and they follow their parent. When createOrUpdateProduct() → saveElement() → setElementUri() runs, Product::getMetafields() queries shopify_data and finds none of the product's metafields yet: it runs before any of the product's child lines have been stored, and a per-product sync has just deleted the previous rows. The URI renders as an empty string, which Craft stores as NULL.
Variants and images have the same timing problem. Anything that reads them during the product save (element events, search keywords, uriFormat) sees stale or missing data.
Suggested fix
Save each product only once all of its child lines have been stored.
- Store the
Product line's record as now, but don't call createOrUpdateProduct() yet. Keep the pending product (e.g. its GID) in a public property on the job so it survives BaseBatchedJob's batch splitting.
- When the next top-level line arrives (no
__parentId), call createOrUpdateProduct() for the pending product, then make the new line the pending one.
- Call it for whatever is still pending at the end of processing, in
after(), before the bulk op is marked completed.
This needs no extra queries and keeps one element save per product. It assumes that a product's child lines (including nested variant metafields) arrive before the next top-level line, which holds for the data we've seen.
If you'd rather not rely on the ordering, a fallback is to keep the current save but record the product GIDs that were processed. After the last batch, call Elements::updateElementSlugAndUri($product, false, false) for each one, or push an UpdateElementSlugsAndUris job. That's cheap for webhook syncs, which touch one GID, but for full syncs it costs a second write per product.
Workaround we're using meanwhile
Listen for ShopifyData EVENT_AFTER_INSERT / EVENT_AFTER_UPDATE. When the saved row is the product's canonical_uri metafield, reload the product and call updateElementSlugAndUri().
Plugin: 7.2.0 (same code in 8.1.0) · Craft: 5.10.14
Summary
ProcessBulkOperationData::processItem()saves the product element as soon as it reads theProductline of the bulk-operation JSONL. The product'sMetafieldlines come after it and haven't been stored yet, so auriFormatthat references metafields renders empty and the product is saved withuri = NULL. Nothing saves the product again once the metafields are stored.For per-product syncs (webhooks →
syncProductByShopifyId()) this breaks existing products too:clearDatais the product GID, sobefore()deletes the product's stored metafields before the element is saved. Every product that changes in Shopify loses its URL.Steps to reproduce
{metafields.canonical_uri}(any metafield will do) and make sure the metafield is populated in Shopify.products/updatewebhook fires, and let the queue run.elements_sites.uriis nowNULLandproduct.urlis null. Itscanonical_urimetafield row is present inshopify_data.Cause
src/jobs/ProcessBulkOperationData.php:Shopify writes child objects as separate JSONL lines carrying a
__parentId, and they follow their parent. WhencreateOrUpdateProduct()→saveElement()→setElementUri()runs,Product::getMetafields()queriesshopify_dataand finds none of the product's metafields yet: it runs before any of the product's child lines have been stored, and a per-product sync has just deleted the previous rows. The URI renders as an empty string, which Craft stores asNULL.Variants and images have the same timing problem. Anything that reads them during the product save (element events, search keywords,
uriFormat) sees stale or missing data.Suggested fix
Save each product only once all of its child lines have been stored.
Productline's record as now, but don't callcreateOrUpdateProduct()yet. Keep the pending product (e.g. its GID) in a public property on the job so it survivesBaseBatchedJob's batch splitting.__parentId), callcreateOrUpdateProduct()for the pending product, then make the new line the pending one.after(), before the bulk op is marked completed.This needs no extra queries and keeps one element save per product. It assumes that a product's child lines (including nested variant metafields) arrive before the next top-level line, which holds for the data we've seen.
If you'd rather not rely on the ordering, a fallback is to keep the current save but record the product GIDs that were processed. After the last batch, call
Elements::updateElementSlugAndUri($product, false, false)for each one, or push anUpdateElementSlugsAndUrisjob. That's cheap for webhook syncs, which touch one GID, but for full syncs it costs a second write per product.Workaround we're using meanwhile
Listen for
ShopifyDataEVENT_AFTER_INSERT/EVENT_AFTER_UPDATE. When the saved row is the product'scanonical_urimetafield, reload the product and callupdateElementSlugAndUri().