Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\CatalogStorefront\Model\DataExporter\Provider;

use Magento\Framework\App\ResourceConnection;

/**
* Adds every category a product is assigned to as `categoryAssignmentIds`, the rows of
* catalog_category_product that core's `getCategoryIds()` reads. The feed's `categoryData` comes
* from the category product index, which adds the anchor ancestors and leaves out the root, so a
* model built from it finds a category core's model does not.
*/
class CategoryAssignments
{
public function __construct(
private readonly ResourceConnection $resourceConnection,
) {
}

public function get(array $values): array
{
$ids = array_unique(array_map(static fn(array $value) => (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;
}
}
7 changes: 5 additions & 2 deletions src/CatalogStorefront/Model/Read/ProductModelBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\CatalogStorefront\Test\Unit\Model\DataExporter\Provider;

use GraphCommerce\CatalogStorefront\Model\DataExporter\Provider\CategoryAssignments;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\DB\Select;
use PHPUnit\Framework\TestCase;

class CategoryAssignmentsTest extends TestCase
{
public function testEveryAssignmentIsOneRowPerProductAndStoreViewInCategoryIdOrder(): void
{
// The root (2) is an assignment as well, as core's getCategoryIds() returns it.
$provider = $this->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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/CatalogStorefront/etc/et_schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
<using field="productId"/>
<using field="storeViewCode"/>
</field>
<field name="categoryAssignmentIds" type="Int" repeated="true" provider="GraphCommerce\CatalogStorefront\Model\DataExporter\Provider\CategoryAssignments">
<using field="productId"/>
<using field="storeViewCode"/>
</field>
<field name="taxClassId" type="Int" provider="GraphCommerce\CatalogStorefront\Model\DataExporter\Provider\TaxClass">
<using field="productId"/>
<using field="storeViewCode"/>
Expand Down