Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a9f2d5e
Fix category product count not including products assigned to the cat…
Nov 17, 2025
f28ff95
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 17, 2025
10a5204
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 19, 2025
93225de
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 19, 2025
dcb78fe
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 20, 2025
58c7e59
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 20, 2025
15db49d
PR review fix
Nov 20, 2025
015bce6
Updating unit test
Nov 20, 2025
4109db7
Adding integration test
Nov 21, 2025
d518ce9
cs code
Nov 21, 2025
a0d86b2
cs code
Nov 21, 2025
df7b608
Optimize the test
Nov 21, 2025
801e996
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 21, 2025
6c3dc6f
fix static check
Nov 21, 2025
9e5ead3
fixing error fixture
Nov 21, 2025
bb6eccc
fixing error fixture
Nov 21, 2025
4fad9fb
fix static issue
Nov 22, 2025
63b9932
fix static issue
Nov 22, 2025
d5f27ef
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 23, 2025
625881e
Remove the BULK_PROCESSING_LIMIT
Nov 23, 2025
b298337
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 24, 2025
4d90a39
fix issue check
Nov 25, 2025
2d0bf92
Merge branch 'fix/category-product-count-missing-self-reference-40263…
Nov 25, 2025
5c1aaa7
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 26, 2025
6384f1b
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 28, 2025
44e7e48
Merge branch '2.4-develop' into fix/category-product-count-missing-se…
mohaelmrabet Nov 30, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -412,14 +412,21 @@ private function getCountFromCategoryTableBulk(
[]
)
->where('ce.entity_id IN (?)', $categoryIds);

$connection->query(
$connection->insertFromSelect(
$selectDescendants,
$tempTableName,
['category_id', 'descendant_id']
)
);
$data = [];
foreach ($categoryIds as $catId) {
$data[] = [
'category_id' => $catId,
'descendant_id' => $catId
];
}
$connection->insertMultiple($tempTableName, $data);
$select = $connection->select()
->from(
['t' => $tempTableName],
Expand Down
290 changes: 290 additions & 0 deletions app/code/Magento/Catalog/Test/Fixture/CategoryTreeWithProducts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
<?php
/**
* Copyright 2025 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\Catalog\Test\Fixture;

use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Catalog\Model\CategoryFactory;
use Magento\Catalog\Model\ProductFactory;
use Magento\Catalog\Model\ResourceModel\Product as ProductResource;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DataObject;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\TestFramework\Fixture\Api\DataMerger;
use Magento\TestFramework\Fixture\Data\ProcessorInterface;
use Magento\TestFramework\Fixture\DataFixtureInterface;

/**
* Generates a multi-level category tree using a configurable fanout array
* and assigns random products to leaf categories.
*
* @SuppressWarnings(PHPMD.TooManyFields)
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*/
class CategoryTreeWithProducts implements DataFixtureInterface
{
private const DEFAULT_DATA = [
'category_identifier' => 'CategoryBulk%uniqid%',
'category_count' => 10,
'product_identifier' => 'ProductBulk%uniqid%',
'product_count' => 0,
'depth' => 1,
'fanout' => [],
'root_id' => null,
];

/**
* @var AdapterInterface
*/
private AdapterInterface $connection;

/**
* @var string
*/
private string $categoryProductTable;

public function __construct(
private readonly ProcessorInterface $dataProcessor,
private readonly DataMerger $dataMerger,
private readonly CategoryRepositoryInterface $categoryRepository,
private readonly CategoryFactory $categoryFactory,
private readonly StoreManagerInterface $storeManager,
private readonly ProductFactory $productFactory,
private readonly ProductResource $productResource,
ResourceConnection $resource
) {
$this->connection = $resource->getConnection();
$this->categoryProductTable = $resource->getTableName('catalog_category_product');
}

/**
* Execute fixture.
*/
public function apply(array $data = []): ?DataObject
{
$data = $this->prepareData($data);
$productIdentifier = $data['product_identifier'];
$productsCount = (int)$data['product_count'];
$categoryIdentifier = $data['category_identifier'];
$categoriesCount = (int)$data['category_count'];
$depth = (int)$data['depth'];
$fanoutInput = $data['fanout'];
$requestedRootId = $data['root_id'];

if ($depth < 0 || $depth > 5) {
throw new \RuntimeException("Parameter 'depth' must be between 0 and 5.");
}

/**
* Resolve root_id
*/
if ($requestedRootId === null) {
$rootId = (int)$this->storeManager->getStore()->getRootCategoryId();
} else {
try {
$root = $this->categoryRepository->get((int)$requestedRootId);
$rootId = (int)$root->getId();
} catch (\Exception $e) {
throw new \RuntimeException("Invalid root_id '{$requestedRootId}': " . $e->getMessage());
}
}

/** Compute fanout */
$fanout = $this->computeFanout($categoriesCount, $depth, $fanoutInput);

/** Create products */
$products = $productsCount > 0
? $this->createProducts($productsCount, $productIdentifier)
: [];

$leafCategories = [];
$parentCategories = [];
$levelParents = [];

/* ---------------- LEVEL 0 ---------------- */
$levelParents[0] = [];
foreach (range(1, $fanout[0]) as $i) {
$levelParents[0][] = $this->createCategoryNode(
"{$categoryIdentifier}_l0_{$rootId}_{$i}",
$rootId,
($depth === 0),
$products,
$leafCategories,
$parentCategories
);
}
if (count($fanout) > 1) {
/* ---------------- LEVELS 1 → depth ---------------- */
for ($level = 1; $level <= $depth; $level++) {
$levelParents[$level] = [];
foreach ($levelParents[$level - 1] as $parentId) {
foreach (range(1, $fanout[$level]) as $i) {
$levelParents[$level][] = $this->createCategoryNode(
"{$categoryIdentifier}_l{$level}_{$parentId}_{$i}",
$parentId,
($level === $depth),
$products,
$leafCategories,
$parentCategories
);
}
}
}
}
return $this->finalize(
$categoriesCount,
$categoryIdentifier,
$products,
$leafCategories,
$parentCategories
);
}

/**
* Compute fanout
*/
private function computeFanout(int $total, int $depth, array $fanout): array
{
$computed = [];
if (count($fanout) && array_sum($fanout) > $total) {
$computed[] = $total;
return $computed;
}
$levels = $depth + 1;
for ($i = 0; $i < $levels; $i++) {
if (isset($fanout[$i]) && $fanout[$i] > 0) {
$computed[$i] = (int)$fanout[$i];
continue;
}
// AUTO distribute
$computed[$i] = max(1, (int)floor(pow($total, 1 / $levels)));
}

return $computed;
}

private function finalize(
int $categoriesCount,
string $identifier,
array $products,
array $leafCategories,
array $parentCategories
): DataObject {

$total = count($parentCategories) + count($leafCategories);
$missing = max(0, $categoriesCount - $total);

for ($i = 1; $i <= $missing; $i++) {
$randomParentId = $parentCategories[random_int(0, count($parentCategories) - 1)];

$this->createCategoryNode(
"{$identifier}_extra_{$randomParentId}_{$i}",
$randomParentId,
true,
$products,
$leafCategories,
$parentCategories
);
}

return new DataObject([
'products' => $products,
'leaf_categories' => $leafCategories,
'all_categories' => array_merge($parentCategories, $leafCategories),
]);
}

/** Create products */
private function createProducts(int $count, string $prefix): array
{
$ids = [];

for ($i = 1; $i <= $count; $i++) {
$product = $this->productFactory->create();
$product->setTypeId('simple')
->setAttributeSetId(4)
->setSku("{$prefix}_{$i}")
->setName("Bulk Test Product {$i}")
->setPrice(10 + $i)
->setVisibility(4)
->setStatus(1);

$this->productResource->save($product);
$ids[] = (int)$product->getId();
}

return $ids;
}

/** Create category node */
private function createCategoryNode(
string $name,
int $parentId,
bool $isLeaf,
array $products,
array &$leafCategories,
array &$parentCategories
): int {

$cat = $this->categoryFactory->create();
$cat->setName($name)
->setIsActive(true)
->setIsAnchor(1)
->setParentId($parentId);

$this->categoryRepository->save($cat);

$id = (int)$cat->getId();

if ($isLeaf && count($products)) {
$this->assignRandomProductsToLeaf($id, $products);
$leafCategories[] = $id;
} else {
$parentCategories[] = $id;
}

return $id;
}

/** Assign products to leaf category */
private function assignRandomProductsToLeaf(int $catId, array $products): void
{
$count = random_int(1, 5);
$selected = [];

for ($i = 0; $i < $count; $i++) {
$selected[] = $products[random_int(0, count($products) - 1)];
}

$selected = array_unique($selected);

$rows = [];
foreach ($selected as $pid) {
$rows[] = [
'category_id' => $catId,
'product_id' => $pid,
'position' => 0
];
}

if ($rows) {
$this->connection->insertMultiple($this->categoryProductTable, $rows);
}
}

private function prepareData(array $data): array
{
return $this->dataProcessor->process(
$this,
$this->dataMerger->merge(self::DEFAULT_DATA, $data)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,28 @@
namespace Magento\Catalog\Test\Unit\Model\ResourceModel\Category;

use Magento\Catalog\Model\Category;
use Magento\Framework\Data\Collection\EntityFactory;
use Magento\Store\Model\Store;
use Psr\Log\LoggerInterface;
use Magento\Framework\Data\Collection\Db\FetchStrategyInterface;
use Magento\Framework\Event\ManagerInterface;
use Magento\Catalog\Model\Product\Visibility;
use Magento\Catalog\Model\ResourceModel\Category as CategoryEntity;
use Magento\Catalog\Model\ResourceModel\Category\Collection;
use Magento\Catalog\Test\Unit\Helper\CategoryTestHelper;
use Magento\Eav\Model\Config;
use Magento\Framework\App\ResourceConnection;
use Magento\Eav\Model\Entity\Attribute\AttributeInterface;
use Magento\Eav\Model\EntityFactory as EavEntityFactory;
use Magento\Eav\Model\ResourceModel\Helper;
use Magento\Framework\Validator\UniversalFactory;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Store\Api\Data\StoreInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Data\Collection\Db\FetchStrategyInterface;
use Magento\Framework\Data\Collection\EntityFactory;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\DB\Select;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Catalog\Model\Product\Visibility;
use Magento\Catalog\Model\ResourceModel\Category\Collection;
use Magento\Catalog\Model\ResourceModel\Category as CategoryEntity;
use Magento\Framework\Event\ManagerInterface;
use Magento\Framework\Validator\UniversalFactory;
use Magento\Store\Api\Data\StoreInterface;
use Magento\Store\Model\Store;
use Magento\Store\Model\StoreManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
* @SuppressWarnings(PHPMD.TooManyFields)
Expand Down Expand Up @@ -265,6 +267,20 @@ public function testLoadProductCountCallsBulkMethodForLargeCategoryCount()
$this->connection->method('select')->willReturn($this->select);
$this->connection->method('insertFromSelect')->willReturn('INSERT QUERY');
$this->connection->method('query')->with('INSERT QUERY')->willReturnSelf();
$withs = [];
foreach ($categoryIds as $categoryId) {
$withs[] = [
'category_id' => $categoryId,
'descendant_id' => $categoryId
];
}
$this->connection
->expects($this->once())
->method('insertMultiple')
->with(
$this->stringContains('temp_category_descendants_'),
$withs
);
$this->select->method('from')->willReturnSelf();
$this->select->method('joinLeft')->willReturnSelf();
$this->select->method('join')->willReturnSelf();
Expand Down
Loading