diff --git a/CLAUDE.md b/CLAUDE.md
index a2c067c..499a7c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -499,7 +499,10 @@ image URLs come from the feed row (`Model/DataExporter/Provider/ImageUrls`).
names, each remaining slice in the shape its reader takes, so no value is
stored twice and no reader parses at request time (`customAttributes` the raw
value per attribute code, an image type and a gallery entry the media file, a
- URL rewrite the request path, `categoryData` as `categoryIds`),
+ URL rewrite the request path, `categoryData` as `categoryIds`, the category product index with
+ the anchor ancestors; `categoryAssignmentIds` (`Model/DataExporter/Provider/CategoryAssignments`)
+ holds the rows of `catalog_category_product`, which the model takes as `category_ids` because core's
+ `getCategoryIds()` reads them),
prices = the feed rows under `prices` with their customer
`group` id (the feed names a group by the hash of its id; the writer maps it
back, rows of an unknown group are dropped; the feed's all-groups row comes
diff --git a/src/CatalogStorefront/Model/DataExporter/Provider/CategoryAssignments.php b/src/CatalogStorefront/Model/DataExporter/Provider/CategoryAssignments.php
new file mode 100644
index 0000000..e521c9c
--- /dev/null
+++ b/src/CatalogStorefront/Model/DataExporter/Provider/CategoryAssignments.php
@@ -0,0 +1,50 @@
+ (int)$value['productId'], $values));
+ if (!$ids) {
+ return [];
+ }
+ $connection = $this->resourceConnection->getConnection();
+ $categories = [];
+ $select = $connection->select()
+ ->from($this->resourceConnection->getTableName('catalog_category_product'), ['product_id', 'category_id'])
+ ->where('product_id IN (?)', $ids)
+ ->order('category_id');
+ foreach ($connection->fetchAll($select) as $row) {
+ $categories[(int)$row['product_id']][] = (int)$row['category_id'];
+ }
+
+ $output = [];
+ foreach ($values as $value) {
+ foreach ($categories[(int)$value['productId']] ?? [] as $categoryId) {
+ $output[$value['storeViewCode'] . '_' . $value['productId'] . '_' . $categoryId] = [
+ 'productId' => $value['productId'],
+ 'storeViewCode' => $value['storeViewCode'],
+ 'categoryAssignmentIds' => $categoryId,
+ ];
+ }
+ }
+
+ return $output;
+ }
+}
diff --git a/src/CatalogStorefront/Model/Read/ProductModelBuilder.php b/src/CatalogStorefront/Model/Read/ProductModelBuilder.php
index 0462628..446cb65 100644
--- a/src/CatalogStorefront/Model/Read/ProductModelBuilder.php
+++ b/src/CatalogStorefront/Model/Read/ProductModelBuilder.php
@@ -98,8 +98,11 @@ public function build(array $document, int $storeId): ?Product
$data['price'] = $fallbackRow['regular'];
}
- if (!empty($document['categoryIds'])) {
- $data['category_ids'] = array_map('strval', $document['categoryIds']);
+ // Core's getCategoryIds() reads the assignments. `categoryIds` holds the category product
+ // index, which adds the anchor ancestors: the current category of a page then applies to
+ // the model where it does not apply to core's, and the product URL takes the category path.
+ if (!empty($document['categoryAssignmentIds'])) {
+ $data['category_ids'] = array_map('strval', $document['categoryAssignmentIds']);
}
foreach ((array)($document['customAttributes'] ?? []) as $code => $value) {
diff --git a/src/CatalogStorefront/Test/Unit/Model/DataExporter/Provider/CategoryAssignmentsTest.php b/src/CatalogStorefront/Test/Unit/Model/DataExporter/Provider/CategoryAssignmentsTest.php
new file mode 100644
index 0000000..9e291a2
--- /dev/null
+++ b/src/CatalogStorefront/Test/Unit/Model/DataExporter/Provider/CategoryAssignmentsTest.php
@@ -0,0 +1,62 @@
+provider([
+ ['product_id' => '1812', 'category_id' => '2'],
+ ['product_id' => '1812', 'category_id' => '30'],
+ ['product_id' => '6', 'category_id' => '4'],
+ ]);
+
+ $output = $provider->get([
+ ['productId' => '1812', 'storeViewCode' => 'default'],
+ ['productId' => '6', 'storeViewCode' => 'default'],
+ ['productId' => '7', 'storeViewCode' => 'default'],
+ ['productId' => '1812', 'storeViewCode' => 'second'],
+ ]);
+
+ self::assertSame([
+ 'default_1812_2' => ['productId' => '1812', 'storeViewCode' => 'default', 'categoryAssignmentIds' => 2],
+ 'default_1812_30' => ['productId' => '1812', 'storeViewCode' => 'default', 'categoryAssignmentIds' => 30],
+ 'default_6_4' => ['productId' => '6', 'storeViewCode' => 'default', 'categoryAssignmentIds' => 4],
+ 'second_1812_2' => ['productId' => '1812', 'storeViewCode' => 'second', 'categoryAssignmentIds' => 2],
+ 'second_1812_30' => ['productId' => '1812', 'storeViewCode' => 'second', 'categoryAssignmentIds' => 30],
+ ], $output);
+ }
+
+ public function testNoProductsMeansNoQuery(): void
+ {
+ $resource = $this->createMock(ResourceConnection::class);
+ $resource->expects(self::never())->method('getConnection');
+
+ self::assertSame([], (new CategoryAssignments($resource))->get([]));
+ }
+
+ private function provider(array $rows): CategoryAssignments
+ {
+ $select = $this->createMock(Select::class);
+ $select->method('from')->willReturnSelf();
+ $select->method('where')->willReturnSelf();
+ $select->method('order')->willReturnSelf();
+ $connection = $this->createMock(AdapterInterface::class);
+ $connection->method('select')->willReturn($select);
+ $connection->method('fetchAll')->willReturn($rows);
+ $resource = $this->createMock(ResourceConnection::class);
+ $resource->method('getConnection')->willReturn($connection);
+ $resource->method('getTableName')->willReturnArgument(0);
+
+ return new CategoryAssignments($resource);
+ }
+}
diff --git a/src/CatalogStorefront/Test/Unit/Model/Read/ProductModelBuilderTest.php b/src/CatalogStorefront/Test/Unit/Model/Read/ProductModelBuilderTest.php
index 4637d10..7fd4a8c 100644
--- a/src/CatalogStorefront/Test/Unit/Model/Read/ProductModelBuilderTest.php
+++ b/src/CatalogStorefront/Test/Unit/Model/Read/ProductModelBuilderTest.php
@@ -118,11 +118,12 @@ public function testAFixedBundleIsBuiltAsABundle(): void
$this->assertSame('1', $this->data['price_type']);
}
- public function testCategoryIdsAreTheDocumentsCategoryIdsAsStrings(): void
+ public function testCategoryIdsAreTheAssignmentsAsStringsNotTheIndex(): void
{
- $this->build(['categoryIds' => [24, 25]]);
+ // The index adds the anchor ancestors 24 and 25 and leaves out the root 2.
+ $this->build(['categoryIds' => [24, 25, 30, 34], 'categoryAssignmentIds' => [2, 30, 34]]);
- $this->assertSame(['24', '25'], $this->data['category_ids']);
+ $this->assertSame(['2', '30', '34'], $this->data['category_ids']);
}
public function testAnAttributeValueComesFromTheCustomAttributeMap(): void
diff --git a/src/CatalogStorefront/etc/et_schema.xml b/src/CatalogStorefront/etc/et_schema.xml
index 0fffc89..1e29044 100644
--- a/src/CatalogStorefront/etc/et_schema.xml
+++ b/src/CatalogStorefront/etc/et_schema.xml
@@ -29,6 +29,10 @@
+
+
+
+