diff --git a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php index 9aa073ceacb85..68b242420ef43 100644 --- a/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php +++ b/app/code/Magento/Catalog/Model/ResourceModel/Category/Collection.php @@ -23,8 +23,6 @@ */ class Collection extends \Magento\Catalog\Model\ResourceModel\Collection\AbstractCollection { - private const BULK_PROCESSING_LIMIT = 400; - /** * Event prefix name * @@ -288,6 +286,7 @@ protected function _loadProductCount() * @SuppressWarnings(PHPMD.UnusedLocalVariable) * @SuppressWarnings(PHPMD.NPathComplexity) * @throws \Magento\Framework\Exception\NoSuchEntityException + * @throws \Zend_Db_Exception */ public function loadProductCount($items, $countRegular = true, $countAnchor = true) { @@ -340,27 +339,25 @@ public function loadProductCount($items, $countRegular = true, $countAnchor = tr if ($countAnchor) { // Retrieve Anchor categories product counts $categoryIds = array_keys($anchor); - $countSelect = $this->getProductsCountQuery($categoryIds, (bool)$websiteId); + $countSelect = $this->getProductsCountQuery($categoryIds, (bool) $websiteId); $categoryProductsCount = $this->_conn->fetchPairs($countSelect); + + // Find categories missing from the SQL result + $categoriesIdsAlreadyIndexed = array_keys($categoryProductsCount); + $missingCategoryIds = array_diff($categoryIds, $categoriesIdsAlreadyIndexed); $countFromCategoryTable = []; - if (count($categoryIds) > self::BULK_PROCESSING_LIMIT) { - $countFromCategoryTable = $this->getCountFromCategoryTableBulk($categoryIds, (int)$websiteId); + if (count($missingCategoryIds)) { + $countFromCategoryTable = $this->getCountFromCategoryTableBulk( + $missingCategoryIds, + (int) $websiteId + ); } - foreach ($anchor as $item) { - $productsCount = 0; - if (count($categoryIds) > self::BULK_PROCESSING_LIMIT) { - if (isset($categoryProductsCount[$item->getId()])) { - $productsCount = (int)$categoryProductsCount[$item->getId()]; - } elseif (isset($countFromCategoryTable[$item->getId()])) { - $productsCount = (int)$countFromCategoryTable[$item->getId()]; - } - } else { - $productsCount = isset($categoryProductsCount[$item->getId()]) - ? (int)$categoryProductsCount[$item->getId()] - : $this->getProductsCountFromCategoryTable($item, $websiteId); - } - $item->setProductCount($productsCount); + $id = $item->getId(); + $productsCount = $categoryProductsCount[$id] + ?? $countFromCategoryTable[$id] + ?? 0; + $item->setProductCount((int) $productsCount); } } return $this; @@ -412,7 +409,6 @@ private function getCountFromCategoryTableBulk( [] ) ->where('ce.entity_id IN (?)', $categoryIds); - $connection->query( $connection->insertFromSelect( $selectDescendants, @@ -420,6 +416,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], @@ -448,45 +452,6 @@ private function getCountFromCategoryTableBulk( return $counts; } - /** - * Get products count using catalog_category_entity table - * - * @param Category $item - * @param string $websiteId - * @return int - */ - private function getProductsCountFromCategoryTable(Category $item, string $websiteId): int - { - $productCount = 0; - - if ($item->getAllChildren()) { - $bind = ['entity_id' => $item->getId(), 'c_path' => $item->getPath() . '/%']; - $select = $this->_conn->select(); - $select->from( - ['main_table' => $this->getProductTable()], - new \Zend_Db_Expr('COUNT(DISTINCT main_table.product_id)') - )->joinInner( - ['e' => $this->getTable('catalog_category_entity')], - 'main_table.category_id=e.entity_id', - [] - )->where( - '(e.entity_id = :entity_id OR e.path LIKE :c_path)' - ); - if ($websiteId) { - $select->join( - ['w' => $this->getProductWebsiteTable()], - 'main_table.product_id = w.product_id', - [] - )->where( - 'w.website_id = ?', - $websiteId - ); - } - $productCount = (int)$this->_conn->fetchOne($select, $bind); - } - return $productCount; - } - /** * Add category path filter * 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..76b86976ded02 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,27 @@ 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\Eav\Model\Config; -use Magento\Framework\App\ResourceConnection; 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\Ddl\Table; 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) @@ -218,61 +219,65 @@ public function testLoadProductCount() : void $this->collection->loadProductCount([]); } - /** - * Test that loadProductCount calls getCountFromCategoryTableBulk - */ - public function testLoadProductCountCallsBulkMethodForLargeCategoryCount() + public function testLoadProductCountWithAnchors() { $websiteId = 1; $storeId = 1; - $categoryCount = 401; $items = []; - $categoryIds = []; - for ($i = 1; $i <= $categoryCount; $i++) { + $categoryIds = range(1, 10); + foreach ($categoryIds as $id) { $category = $this->getMockBuilder(Category::class) ->addMethods(['getIsAnchor']) ->onlyMethods(['getId', 'setProductCount']) ->disableOriginalConstructor() ->getMock(); - $category->method('getId')->willReturn($i); + $category->method('getId')->willReturn($id); $category->method('getIsAnchor')->willReturn(true); - $category->expects($this->once())->method('setProductCount')->with(5); - $items[$i] = $category; - $categoryIds[] = $i; + $category + ->expects($this->once()) + ->method('setProductCount')->with(5); + $items[$id] = $category; } - $storeMock = $this->createMock(Store::class); - $storeMock->method('getWebsiteId')->willReturn($websiteId); - $this->storeManager->method('getStore')->with($storeId)->willReturn($storeMock); - $this->connection->method('select')->willReturn($this->select); - $counts = array_fill_keys($categoryIds, 5); - $tableMock = $this->createMock(\Magento\Framework\DB\Ddl\Table::class); + + $store = $this->createMock(Store::class); + $store->method('getWebsiteId')->willReturn($websiteId); + $this->storeManager->method('getStore')->with($storeId)->willReturn($store); + + $indexedIds = array_slice($categoryIds, 0, 5); + $firstCounts = array_fill_keys($indexedIds, 5); + + $missingIds = array_diff($categoryIds, $indexedIds); + $fallbackCounts = array_fill_keys($missingIds, 5); + + $this->connection->method('fetchPairs') + ->willReturnOnConsecutiveCalls($firstCounts, $fallbackCounts); + + $tableMock = $this->createMock(Table::class); $tableMock->method('addColumn')->willReturnSelf(); $tableMock->method('addIndex')->willReturnSelf(); - $this->connection->method('newTable') - ->with($this->stringContains('temp_category_descendants_')) - ->willReturn($tableMock); + + $this->connection->method('newTable')->willReturn($tableMock); $this->connection->expects($this->once())->method('createTemporaryTable')->with($tableMock); - $this->connection->expects($this->once())->method('dropTemporaryTable') - ->with($this->stringContains('temp_category_descendants_')); - $this->select->method('from')->willReturnSelf(); - $this->select->expects($this->once())->method('joinInner') - ->with( - ['ce2' => null], - 'ce2.path LIKE CONCAT(ce.path, \'/%\')', - [] - )->willReturnSelf(); - $this->select->method('where')->willReturnSelf(); + $this->connection->expects($this->once())->method('dropTemporaryTable'); + + $this->connection->method('insertFromSelect')->willReturn('SQL'); + $this->connection->method('query')->with('SQL'); + + $expectedData = []; + foreach ($missingIds as $id) { + $expectedData[] = ['category_id' => $id, 'descendant_id' => $id]; + } + $this->connection->expects($this->once())->method('insertMultiple') + ->with($this->stringContains('temp_category_descendants_'), $expectedData); + $this->connection->method('select')->willReturn($this->select); - $this->connection->method('insertFromSelect')->willReturn('INSERT QUERY'); - $this->connection->method('query')->with('INSERT QUERY')->willReturnSelf(); $this->select->method('from')->willReturnSelf(); + $this->select->method('joinInner')->willReturnSelf(); $this->select->method('joinLeft')->willReturnSelf(); $this->select->method('join')->willReturnSelf(); $this->select->method('where')->willReturnSelf(); $this->select->method('group')->willReturnSelf(); - $this->connection->method('fetchPairs') - ->with($this->isInstanceOf(Select::class)) - ->willReturn($counts); + $this->collection->setProductStoreId($storeId); $this->collection->loadProductCount($items, false, true); } 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 e8ba45e028f87..10e2494b89943 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 @@ -7,12 +7,35 @@ namespace Magento\Catalog\Model\ResourceModel\Category; -class CollectionTest extends \PHPUnit\Framework\TestCase +use Magento\Catalog\Model\Category; +use Magento\Catalog\Test\Fixture\Category as CategoryFixture; +use Magento\Catalog\Test\Fixture\Product as ProductFixture; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Fixture\AppArea; +use Magento\TestFramework\Fixture\AppIsolation; +use Magento\TestFramework\Fixture\DataFixture; +use Magento\TestFramework\Fixture\DbIsolation; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\Catalog\Model\ResourceModel\Category\Collection as CategoryCollection; +use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory; +use PHPUnit\Framework\TestCase; + +/** + * Tests collection category + * + * @see \Magento\Catalog\Model\ResourceModel\Category\Collection + */ +class CollectionTest extends TestCase { /** - * @var \Magento\Catalog\Model\ResourceModel\Category\Collection + * @var CategoryCollection + */ + private Collection $collection; + + /** + * @var CollectionFactory */ - private $collection; + private CollectionFactory $categoryCollectionFactory; /** * Sets up the fixture, for example, opens a network connection. @@ -20,16 +43,16 @@ class CollectionTest extends \PHPUnit\Framework\TestCase */ protected function setUp(): void { - $this->collection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create( - \Magento\Catalog\Model\ResourceModel\Category\Collection::class - ); + $objectManager = Bootstrap::getObjectManager(); + $this->collection = Bootstrap::getObjectManager()->create(CategoryCollection::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(); } @@ -42,7 +65,7 @@ public function testJoinUrlRewriteOnDefault() { $categories = $this->collection->joinUrlRewrite()->addPathFilter('1/2/3'); $this->assertCount(1, $categories); - /** @var $category \Magento\Catalog\Model\Category */ + /** @var $category Category */ $category = $categories->getFirstItem(); $this->assertStringEndsWith('category.html', $category->getUrl()); } @@ -54,13 +77,69 @@ 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'); $this->assertCount(1, $categories); - /** @var $category \Magento\Catalog\Model\Category */ + /** @var $category Category */ $category = $categories->getFirstItem(); $this->assertStringEndsWith('category-3-on-2.html', $category->getUrl()); } + + #[ + DataFixture(CategoryFixture::class, ['name' => 'TC L1 Root', 'parent_id' => '2', 'is_anchor' => 1], 'c1'), + DataFixture(CategoryFixture::class, ['name' => 'TC L2 A', 'parent_id' => '$c1.id$', 'is_anchor' => 1], 'c11'), + DataFixture(CategoryFixture::class, ['name' => 'TC L2 B', 'parent_id' => '$c1.id$', 'is_anchor' => 1], 'c12'), + DataFixture(CategoryFixture::class, ['name' => 'TC L2 C', 'parent_id' => '$c1.id$', 'is_anchor' => 0], 'c13'), + DataFixture( + CategoryFixture::class, + ['name' => 'TC L3 A1', 'parent_id' => '$c11.id$', 'is_anchor' => 1], + 'c1111' + ), + DataFixture( + CategoryFixture::class, + ['name' => 'TC L3 A2', 'parent_id' => '$c11.id$', 'is_anchor' => 1], + 'c1112' + ), + DataFixture( + CategoryFixture::class, + ['name' => 'TC L3 C1', 'parent_id' => '$c13.id$', 'is_anchor' => 0], + 'c1113' + ), + DataFixture(ProductFixture::class, ['sku' => 'TP-1A', 'category_ids' => ['$c12.id$']], 'p1'), + DataFixture(ProductFixture::class, ['sku' => 'TP-2A', 'category_ids' => ['$c1111.id$']], 'p2'), + DataFixture(ProductFixture::class, ['sku' => 'TP-3B', 'category_ids' => ['$c1112.id$', '$c1113.id$']], 'p3'), + DataFixture(ProductFixture::class, ['sku' => 'TP-4B', 'category_ids' => ['$c1112.id$', '$c1113.id$']], 'p4'), + AppArea('adminhtml'), + DbIsolation(true), + AppIsolation(true) + ] + public function testLoadProductCountWithoutIndex() + { + $collection = $this->categoryCollectionFactory->create(); + $collection->addAttributeToSelect(['name', 'is_anchor']); + $collection->addAttributeToFilter('name', ['like' => 'TC L%']); + $collection->setLoadProductCount(true); + $collection->load(); + + $expected = [ + 'TC L1 Root' => 4, + 'TC L2 A' => 3, + 'TC L2 B' => 1, + 'TC L2 C' => 0, + 'TC L3 A1' => 1, + 'TC L3 A2' => 2, + 'TC L3 C1' => 2 + ]; + + foreach ($collection as $category) { + $name = $category->getName(); + $this->assertEquals( + $expected[$name], + (int)$category->getProductCount(), + "Product count incorrect for category $name" + ); + } + } }