diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php index 9aa073ceacb85..df841ae996ca6 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php @@ -412,7 +412,6 @@ private function getCountFromCategoryTableBulk( [] ) ->where('ce.entity_id IN (?)', $categoryIds); - $connection->query( $connection->insertFromSelect( $selectDescendants, @@ -420,6 +419,14 @@ private function getCountFromCategoryTableBulk( ['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], diff --git a/app/code/Magento/Catalog/Test/Fixture/CategoryTreeWithProducts.php b/app/code/Magento/Catalog/Test/Fixture/CategoryTreeWithProducts.php new file mode 100644 index 0000000000000..bc28d7bcdd2ef --- /dev/null +++ b/app/code/Magento/Catalog/Test/Fixture/CategoryTreeWithProducts.php @@ -0,0 +1,290 @@ + '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) + ); + } +} diff --git a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Category/CollectionTest.php b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Category/CollectionTest.php index f29bfedc48511..7aca9f966241b 100644 --- a/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Category/CollectionTest.php +++ b/app/code/Magento/Catalog/Test/Unit/Model/ResourceModel/Category/CollectionTest.php @@ -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) @@ -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(); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Category/CollectionTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Category/CollectionTest.php index 874862b725341..304a5bf65d2f6 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Category/CollectionTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/ResourceModel/Category/CollectionTest.php @@ -1,18 +1,34 @@ collection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ResourceModel\Category\Collection::class - ); + $objectManager = Bootstrap::getObjectManager(); + $this->collection = Bootstrap::getObjectManager()->create(Collection::class); + $this->categoryCollectionFactory = $objectManager->get(CollectionFactory::class); } - protected function setDown() + protected function tearDown(): void { /* Refresh stores memory cache after store deletion */ - \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get( - \Magento\Store\Model\StoreManagerInterface::class + Bootstrap::getObjectManager()->get( + StoreManagerInterface::class )->reinitStores(); } @@ -54,7 +70,7 @@ public function testJoinUrlRewriteOnDefault() */ public function testJoinUrlRewriteNotOnDefaultStore() { - $store = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + $store = Bootstrap::getObjectManager() ->create(\Magento\Store\Model\Store::class); $storeId = $store->load('second_category_store', 'code')->getId(); $categories = $this->collection->setStoreId($storeId)->joinUrlRewrite()->addPathFilter('1/2/3'); @@ -63,4 +79,49 @@ public function testJoinUrlRewriteNotOnDefaultStore() $category = $categories->getFirstItem(); $this->assertStringEndsWith('category-3-on-2.html', $category->getUrl()); } + + #[ + DataFixture ( + CategoryTreeWithProductsFixture::class, + [ + 'category_identifier' => 'bulk_test_123_cat', + 'category_count' => 401, + 'product_identifier' => 'bulk_test_123_prd', + 'product_count' => 20, + 'depth' => 3 + ], + 'cats' + ), + AppArea('adminhtml'), + DbIsolation(true), + AppIsolation(true) + ] + public function testBulkProcessingModeIsTriggered() + { + /** @var CategoryCollection $collection */ + $collection = $this->categoryCollectionFactory->create(); + $collection->addAttributeToSelect('*'); + $collection->addAttributeToFilter('name', ['like' => 'bulk_test_123_cat%']); + $collection->setLoadProductCount(true); + $collection->load(); + + $this->assertGreaterThan( + 400, + $collection->getSize(), + 'Bulk limit path not triggered.' + ); + + foreach ($collection as $category) { + $productCount = $category->getProductCount(); + $this->assertNotNull( + $productCount, + 'ProductCount missing for category ' . $category->getId() + ); + $this->assertGreaterThan( + 0, + $productCount, + sprintf('Invalid product count for category %d.', $category->getId()) + ); + } + } }