Commit 1d6153cd106 for woocommerce
commit 1d6153cd106b81da1779100644db4a79805c50bb
Author: Wesley Rosa <wesleyjrosa@gmail.com>
Date: Wed Jun 24 12:19:22 2026 -0300
Extract delivery-agnostic ProductShapeMapperInterface in ProductFeed (#65720)
* Extract delivery-agnostic ProductShapeMapperInterface in ProductFeed
* Add changelog entry for ProductShapeMapperInterface extraction
* Document future direction for product-export abstractions in README
* Deprecate ProductMapperInterface in favor of ProductShapeMapperInterface
* Shorten changelog entry
* Add reflection-based signature guard for ProductShapeMapperInterface
diff --git a/plugins/woocommerce/changelog/65389-dev-product-shape-mapper-interface b/plugins/woocommerce/changelog/65389-dev-product-shape-mapper-interface
new file mode 100644
index 00000000000..f272d4fafbe
--- /dev/null
+++ b/plugins/woocommerce/changelog/65389-dev-product-shape-mapper-interface
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add delivery-agnostic ProductShapeMapperInterface to the ProductFeed framework, deprecating ProductMapperInterface in its favor.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
index 8a07a1721be..ca94d2d6320 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
@@ -9,17 +9,23 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+
/**
* Product Mapper Interface.
*
+ * Push-feed flavor of the product-shape mapping contract: implementations map a
+ * product to a feed row that is validated by a FeedValidatorInterface and written
+ * to a FeedInterface. The mapping contract itself (map_product()) is inherited
+ * from ProductShapeMapperInterface.
+ *
+ * Existing implementations keep working unchanged and automatically satisfy
+ * ProductShapeMapperInterface; they should migrate to implementing that
+ * interface directly before this one is removed.
+ *
* @since 10.5.0
+ * @since 11.0.0 Extends ProductShapeMapperInterface; the map_product() contract is inherited unchanged.
+ * @deprecated 11.0.0 Implement Mapping\ProductShapeMapperInterface instead.
*/
-interface ProductMapperInterface {
- /**
- * Map a product to a feed row.
- *
- * @param \WC_Product $product The product to map.
- * @return array The feed row.
- */
- public function map_product( \WC_Product $product ): array;
+interface ProductMapperInterface extends ProductShapeMapperInterface {
}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
index cfc80bd6d49..9d2351e8bfd 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
if ( ! defined( 'ABSPATH' ) ) {
@@ -32,9 +33,9 @@ class ProductWalker {
/**
* The product mapper.
*
- * @var ProductMapperInterface
+ * @var ProductShapeMapperInterface
*/
- private ProductMapperInterface $mapper;
+ private ProductShapeMapperInterface $mapper;
/**
* The feed.
@@ -83,15 +84,15 @@ class ProductWalker {
*
* This class will not be available through DI. Instead, it needs to be instantiated directly.
*
- * @param ProductMapperInterface $mapper The product mapper.
- * @param FeedValidatorInterface $validator The feed validator.
- * @param FeedInterface $feed The feed.
- * @param ProductLoader $product_loader The product loader.
- * @param MemoryManager $memory_manager The memory manager.
- * @param array $query_args The query arguments.
+ * @param ProductShapeMapperInterface $mapper The product mapper.
+ * @param FeedValidatorInterface $validator The feed validator.
+ * @param FeedInterface $feed The feed.
+ * @param ProductLoader $product_loader The product loader.
+ * @param MemoryManager $memory_manager The memory manager.
+ * @param array $query_args The query arguments.
*/
private function __construct(
- ProductMapperInterface $mapper,
+ ProductShapeMapperInterface $mapper,
FeedValidatorInterface $validator,
FeedInterface $feed,
ProductLoader $product_loader,
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
index 32e97d1978d..008a2df2fc9 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
@@ -11,7 +11,7 @@ namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
-use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -77,9 +77,14 @@ interface IntegrationInterface {
/**
* Get the product mapper for the provider.
*
- * @return ProductMapperInterface The product mapper.
+ * Implementations may narrow the return type to a concrete mapper class
+ * (or, during its deprecation window, the deprecated ProductMapperInterface)
+ * thanks to return type covariance.
+ *
+ * @since 11.0.0 Return type widened from ProductMapperInterface to ProductShapeMapperInterface.
+ * @return ProductShapeMapperInterface The product mapper.
*/
- public function get_product_mapper(): ProductMapperInterface;
+ public function get_product_mapper(): ProductShapeMapperInterface;
/**
* Get the feed validator for the provider.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
index 52a706750e9..fcb82f4a195 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
@@ -9,7 +9,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
-use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
use WC_Product;
use WC_REST_Products_Controller;
use WC_REST_Product_Variations_Controller;
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* @since 10.5.0
*/
-class ProductMapper implements ProductMapperInterface {
+class ProductMapper implements ProductShapeMapperInterface {
/**
* Fields to include in the product mapping.
*
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterface.php
new file mode 100644
index 00000000000..e2317dff5a8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterface.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Product Shape Mapper Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Mapping;
+
+/**
+ * Minimal contract for mapping a WooCommerce product to an arbitrary array shape.
+ *
+ * This interface carries no delivery semantics: implementations may produce feed
+ * rows, REST payloads, live query results, or any other shape. It can be consumed
+ * by push-feed integrations (via the ProductWalker / FeedInterface machinery) and
+ * by pull/live-query integrations alike, without taking a dependency on file or
+ * CSV delivery.
+ *
+ * This interface supersedes the Feed namespace's ProductMapperInterface, which
+ * extends it and is deprecated: all integrations, push-feed ones included, should
+ * implement this interface directly.
+ *
+ * @since 11.0.0
+ */
+interface ProductShapeMapperInterface {
+ /**
+ * Map a product to an array shape.
+ *
+ * @param \WC_Product $product The product to map.
+ * @return array The mapped product data.
+ */
+ public function map_product( \WC_Product $product ): array;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/README.md b/plugins/woocommerce/src/Internal/ProductFeed/README.md
new file mode 100644
index 00000000000..c42097072b1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/README.md
@@ -0,0 +1,119 @@
+# Product Feed framework
+
+The Product Feed framework provides reusable building blocks for exposing the product catalog to external systems. It separates two concerns that integrations frequently need:
+
+1. **Product-shape mapping** — turning a `WC_Product` into an array shape that a consumer understands.
+2. **Feed delivery** — assembling the mapped shapes into a file (JSON, CSV, …) and handing it off to a destination.
+
+Push-feed integrations (for example, the built-in POS Catalog integration or the Stripe Agentic Commerce feed) use both. Pull/live-query integrations (for example, UCP catalog endpoints that run a fresh query on every request) only need the mapping concern and can use it without taking any dependency on file or CSV delivery.
+
+## Directory layout
+
+```text
+ProductFeed/
+├── ProductFeed.php # Entry point; integration registration
+├── Mapping/
+│ └── ProductShapeMapperInterface.php # Delivery-agnostic mapping contract
+├── Feed/
+│ ├── ProductMapperInterface.php # Deprecated alias of the mapping contract
+│ ├── FeedInterface.php # Feed assembly (start / add_entry / end)
+│ ├── FeedValidatorInterface.php # Per-entry validation for feeds
+│ ├── ProductWalker.php # Batched iteration of the catalog into a feed
+│ ├── ProductLoader.php # Thin wrapper around wc_get_products()
+│ └── WalkerProgress.php # Progress data for walker callbacks
+├── Integrations/
+│ ├── IntegrationInterface.php # Contract for push-feed integrations
+│ ├── IntegrationRegistry.php # Holds registered integrations
+│ └── POSCatalog/ # Built-in POS catalog integration
+├── Storage/
+│ └── JsonFileFeed.php # File-backed JSON feed implementation
+└── Utils/ # Memory and string helpers
+```
+
+## Product-shape mapping
+
+`Mapping\ProductShapeMapperInterface` is the minimal contract shared by all consumers:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+
+class MyCatalogMapper implements ProductShapeMapperInterface {
+ public function map_product( \WC_Product $product ): array {
+ return array(
+ 'id' => (string) $product->get_id(),
+ 'title' => $product->get_name(),
+ // ... whatever shape your consumer needs.
+ );
+ }
+}
+```
+
+The interface carries no delivery semantics. The returned array can be a feed row, a REST payload, a live query result, or anything else. This makes a mapper implementation reusable across delivery models:
+
+- **Pull/live-query consumers** (REST controllers that query products per request) type against `ProductShapeMapperInterface` directly and call `map_product()` on each result of their own query. They never touch `FeedInterface`, validators, or files.
+- **Push-feed integrations** also implement `ProductShapeMapperInterface`; the framework's walker and feed machinery consume the mapper through this contract. The older `Feed\ProductMapperInterface` (which extends `ProductShapeMapperInterface` without adding methods) is deprecated: existing implementations keep working during the transition window, but new code should implement `ProductShapeMapperInterface` directly.
+
+## Push-feed integrations
+
+A push-feed integration implements `Integrations\IntegrationInterface`, which composes the mapping contract with feed-specific collaborators:
+
+- `get_product_mapper(): ProductShapeMapperInterface` — the mapper producing one row per product.
+- `get_feed_validator(): FeedValidatorInterface` — validates each mapped row; rows with issues are skipped.
+- `create_feed(): FeedInterface` — the feed being assembled (`Storage\JsonFileFeed` is a ready-made file-backed implementation).
+- `get_product_feed_query_args(): array` — extra `wc_get_products()` arguments scoping which products are included.
+
+`Feed\ProductWalker` ties these together: it iterates the catalog in batches (with memory management and progress reporting), runs every product through the mapper, drops rows the validator rejects, and writes the rest to the feed:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+
+$feed = $integration->create_feed();
+$walker = ProductWalker::from_integration( $integration, $feed );
+$walker->set_batch_size( 100 )->walk();
+
+$file_path = $feed->get_file_path();
+```
+
+Integrations register themselves through `ProductFeed::register_integration()`:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\ProductFeed;
+
+wc_get_container()->get( ProductFeed::class )->register_integration( new MyIntegration() );
+```
+
+## Pull/live-query consumers
+
+A pull consumer keeps its own querying and transport, and reuses only the mapping abstraction:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+
+class MyCatalogController {
+ private ProductShapeMapperInterface $mapper;
+
+ public function __construct( ProductShapeMapperInterface $mapper ) {
+ $this->mapper = $mapper;
+ }
+
+ public function search( \WP_REST_Request $request ): \WP_REST_Response {
+ $products = wc_get_products( array( /* request-derived args */ ) );
+
+ return new \WP_REST_Response(
+ array( 'products' => array_map( array( $this->mapper, 'map_product' ), $products ) )
+ );
+ }
+}
+```
+
+Because the mapper is delivery-agnostic, the same implementation can also back a push feed for the same catalog shape later, by plugging it into an `IntegrationInterface`.
+
+## Scope and future direction
+
+The framework currently models two consumption patterns: push feeds (file assembly and delivery) and pull/live-query mapping. A third pattern exists in the WooCommerce ecosystem — batched API push with change-trigger hooks, as implemented by Google Listings & Ads (`WCProductAdapter` → `ProductSyncer` → `BatchProductHelper` → `SyncerHooks`). That model is the most mature product-export abstraction in the ecosystem and is a candidate blueprint for a future, richer export framework that push-feed, pull/query, and API-push integrations could all share. Migrating existing API-push integrations onto this framework is intentionally out of scope for now; `ProductShapeMapperInterface` is the shared mapping kernel any such evolution would build on.
+
+## Backwards compatibility notes
+
+- `Feed\ProductMapperInterface` is deprecated, not removed: it extends `Mapping\ProductShapeMapperInterface` without redeclaring methods, so existing implementations keep working unchanged and automatically satisfy the new interface. They should migrate to implementing `ProductShapeMapperInterface` directly before the deprecated interface is removed in a future release.
+- `IntegrationInterface::get_product_mapper()` declares `ProductShapeMapperInterface` as its return type. Implementations may keep narrowing it to `ProductMapperInterface` or a concrete mapper class (return type covariance).
+- These classes live under `Internal` and are not a public API; interfaces may evolve between minor versions.
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterfaceTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterfaceTest.php
new file mode 100644
index 00000000000..c995a3c5f26
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterfaceTest.php
@@ -0,0 +1,155 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Mapping;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductLoader;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ProductMapper;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
+use WC_Helper_Product;
+use WC_Product;
+
+/**
+ * Tests for the ProductShapeMapperInterface contract.
+ */
+class ProductShapeMapperInterfaceTest extends \WC_Unit_Test_Case {
+ /**
+ * Test container.
+ *
+ * @var TestContainer
+ */
+ private $test_container;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->test_container = wc_get_container();
+ }
+
+ /**
+ * Clean up test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ $this->test_container->reset_all_replacements();
+ }
+
+ /**
+ * @testdox The deprecated push-feed ProductMapperInterface should remain a subtype of ProductShapeMapperInterface, so existing feed mappers keep working during the deprecation window.
+ */
+ public function test_feed_product_mapper_interface_is_a_product_shape_mapper(): void {
+ $this->assertTrue(
+ is_subclass_of( ProductMapperInterface::class, ProductShapeMapperInterface::class ),
+ 'The deprecated ProductMapperInterface should extend ProductShapeMapperInterface'
+ );
+ }
+
+ /**
+ * @testdox The built-in POS catalog mapper should satisfy ProductShapeMapperInterface.
+ */
+ public function test_pos_catalog_mapper_is_a_product_shape_mapper(): void {
+ $mapper = wc_get_container()->get( ProductMapper::class );
+
+ $this->assertInstanceOf(
+ ProductShapeMapperInterface::class,
+ $mapper,
+ 'The POS catalog mapper should be consumable through the delivery-agnostic interface'
+ );
+ }
+
+ /**
+ * @testdox The ProductShapeMapperInterface contract (param + return type) should be stable; changing it is a conscious BC decision.
+ */
+ public function test_product_shape_mapper_interface_signature_is_stable(): void {
+ $method = new \ReflectionMethod( ProductShapeMapperInterface::class, 'map_product' );
+ $params = $method->getParameters();
+
+ $this->assertCount( 1, $params, 'map_product() should take exactly one parameter.' );
+ $this->assertSame( WC_Product::class, (string) $params[0]->getType(), 'map_product() should accept a WC_Product.' );
+ $this->assertSame( 'array', (string) $method->getReturnType(), 'map_product() should return an array.' );
+ }
+
+ /**
+ * Demonstrates the decoupling-from-feed-machinery path: an implementation can be
+ * instantiated and invoked with no container, validator, or feed present. This is a
+ * structural/usability demonstration, not a signature guard — see
+ * test_product_shape_mapper_interface_signature_is_stable() for the contract tripwire.
+ *
+ * @testdox A pull/query consumer should be able to use a shape mapper standalone, without any feed machinery.
+ */
+ public function test_shape_mapper_is_usable_without_feed_machinery(): void {
+ $mapper = new class() implements ProductShapeMapperInterface {
+ /**
+ * Map a product to an array shape.
+ *
+ * @param WC_Product $product The product to map.
+ * @return array The mapped product data.
+ */
+ public function map_product( WC_Product $product ): array {
+ return array(
+ 'id' => (string) $product->get_id(),
+ 'title' => $product->get_name(),
+ );
+ }
+ };
+
+ $product = WC_Helper_Product::create_simple_product();
+
+ $mapped = $mapper->map_product( $product );
+
+ $this->assertSame( (string) $product->get_id(), $mapped['id'] );
+ $this->assertSame( $product->get_name(), $mapped['title'] );
+ }
+
+ /**
+ * @testdox The ProductWalker should accept a mapper that only implements ProductShapeMapperInterface, not the push-feed flavored interface.
+ */
+ public function test_product_walker_accepts_plain_shape_mapper(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $mock_loader = $this->createMock( ProductLoader::class );
+ $mock_loader->method( 'get_products' )->willReturn(
+ (object) array(
+ 'products' => array( $product ),
+ 'total' => 1,
+ 'max_num_pages' => 1,
+ )
+ );
+ $this->test_container->replace( ProductLoader::class, $mock_loader );
+
+ $mock_memory_manager = $this->createMock( MemoryManager::class );
+ $mock_memory_manager->method( 'get_available_memory' )->willReturn( 100 );
+ $this->test_container->replace( MemoryManager::class, $mock_memory_manager );
+
+ $mock_mapper = $this->createMock( ProductShapeMapperInterface::class );
+ $mock_mapper->expects( $this->once() )
+ ->method( 'map_product' )
+ ->with( $this->isInstanceOf( WC_Product::class ) )
+ ->willReturn( array( 'id' => $product->get_id() ) );
+
+ $mock_validator = $this->createMock( FeedValidatorInterface::class );
+ $mock_validator->method( 'validate_entry' )->willReturn( array() );
+
+ $mock_integration = $this->createMock( IntegrationInterface::class );
+ $mock_integration->method( 'get_product_mapper' )->willReturn( $mock_mapper );
+ $mock_integration->method( 'get_feed_validator' )->willReturn( $mock_validator );
+ $mock_integration->method( 'get_product_feed_query_args' )->willReturn( array() );
+
+ $mock_feed = $this->createMock( FeedInterface::class );
+ $mock_feed->expects( $this->once() )
+ ->method( 'add_entry' )
+ ->with( array( 'id' => $product->get_id() ) );
+
+ $processed = ProductWalker::from_integration( $mock_integration, $mock_feed )->walk();
+
+ $this->assertSame( 1, $processed, 'The walker should process the single product through the plain shape mapper' );
+ }
+}