From 05a0dcd565adeee8fa69ee17109bce8bfc9b228f Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Thu, 30 Jul 2026 10:36:59 -0400 Subject: [PATCH 1/2] fix(inventory-configurable-product-indexer): harden the child status join of the parent stock index --- .../Indexer/SelectBuilder.php | 12 ++- .../Test/Unit/Indexer/SelectBuilderTest.php | 78 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php index 02cd9b94e55..406f19d0db4 100644 --- a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php @@ -24,6 +24,7 @@ use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameBuilder; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface; use Magento\InventoryIndexer\Indexer\SelectBuilderInterface; +use Magento\Store\Model\Store; /** * Get configurable product for given stock select builder @@ -122,6 +123,11 @@ public function execute(int $stockId): Select $manageStock = "($manageStock)"; } + $enabledChildIsSalable = sprintf( + 'MAX(IF(product_status.value = %d, stock.is_salable, 0))', + ProductStatus::STATUS_ENABLED + ); + $select = $connection->select() ->from( ['stock' => $indexTableName], @@ -129,7 +135,7 @@ public function execute(int $stockId): Select IndexStructure::SKU => 'parent_product_entity.sku', IndexStructure::QUANTITY => 'SUM(stock.quantity)', IndexStructure::IS_SALABLE => - "IF(inventory_stock_item.is_in_stock = 0 AND $manageStock, 0, MAX(stock.is_salable))", + "IF(inventory_stock_item.is_in_stock = 0 AND $manageStock, 0, $enabledChildIsSalable)", ] )->joinInner( ['product_entity' => $this->resourceConnection->getTableName('catalog_product_entity')], @@ -148,11 +154,11 @@ public function execute(int $stockId): Select 'inventory_stock_item.product_id = parent_product_entity.entity_id' . ' AND inventory_stock_item.stock_id = ' . $this->defaultStockProvider->getId(), [] - )->joinInner( + )->joinLeft( ['product_status' => $this->resourceConnection->getTableName('catalog_product_entity_int')], "product_entity.$linkField = product_status.$linkField" . " AND product_status.attribute_id = $statusAttributeId" - . ' AND product_status.value = ' . ProductStatus::STATUS_ENABLED, + . ' AND product_status.store_id = ' . Store::DEFAULT_STORE_ID, [] ) ->group(['parent_product_entity.sku']) diff --git a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php index d45c1f35144..41266b65b4d 100644 --- a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php +++ b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -7,6 +7,7 @@ namespace Magento\InventoryConfigurableProductIndexer\Test\Unit\Indexer; +use Magento\Catalog\Model\Product\Attribute\Source\Status as ProductStatus; use Magento\Catalog\Model\ResourceModel\Eav\Attribute; use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; @@ -17,9 +18,11 @@ use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use Magento\InventoryCatalogApi\Api\DefaultStockProviderInterface; use Magento\InventoryConfigurableProductIndexer\Indexer\SelectBuilder; +use Magento\InventoryIndexer\Indexer\IndexStructure; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexName; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameBuilder; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface; +use Magento\Store\Model\Store; use PHPUnit\Framework\TestCase; /** @@ -82,4 +85,79 @@ public function testExecuteOrdersBySkuAscending(): void $selectBuilder->execute(2); } + + public function testDisabledChildrenAreIgnoredWithoutDroppingTheParentRow(): void + { + $columns = []; + $joinConditions = []; + + $connection = $this->createMock(AdapterInterface::class); + + $select = $this->createMock(Select::class); + foreach (['joinInner', 'where', 'group', 'order'] as $method) { + $select->method($method)->willReturnSelf(); + } + $select->method('from') + ->willReturnCallback(function ($table, $cols) use ($select, &$columns) { + $columns = $cols; + return $select; + }); + $select->method('joinLeft') + ->willReturnCallback(function ($table, $condition) use ($select, &$joinConditions) { + $joinConditions[array_key_first($table)] = $condition; + return $select; + }); + $connection->method('select')->willReturn($select); + + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($connection); + $resourceConnection->method('getTableName')->willReturnArgument(0); + + $indexNameBuilder = $this->createMock(IndexNameBuilder::class); + $indexNameBuilder->method('setIndexId')->willReturnSelf(); + $indexNameBuilder->method('addDimension')->willReturnSelf(); + $indexNameBuilder->method('setAlias')->willReturnSelf(); + $indexNameBuilder->method('build')->willReturn($this->createMock(IndexName::class)); + + $indexNameResolver = $this->createMock(IndexNameResolverInterface::class); + $indexNameResolver->method('resolveName')->willReturn('inventory_stock_2'); + + $metadata = $this->createMock(EntityMetadataInterface::class); + $metadata->method('getLinkField')->willReturn('row_id'); + $metadataPool = $this->createMock(MetadataPool::class); + $metadataPool->method('getMetadata')->willReturn($metadata); + + $defaultStockProvider = $this->createMock(DefaultStockProviderInterface::class); + $defaultStockProvider->method('getId')->willReturn(1); + + $statusAttribute = $this->createMock(Attribute::class); + $statusAttribute->method('getId')->willReturn(97); + $eavConfig = $this->createMock(Config::class); + $eavConfig->method('getAttribute')->willReturn($statusAttribute); + + $selectBuilder = (new ObjectManager($this))->getObject( + SelectBuilder::class, + [ + 'resourceConnection' => $resourceConnection, + 'indexNameBuilder' => $indexNameBuilder, + 'indexNameResolver' => $indexNameResolver, + 'metadataPool' => $metadataPool, + 'defaultStockProvider' => $defaultStockProvider, + 'eavConfig' => $eavConfig, + ] + ); + + $selectBuilder->execute(2); + + self::assertStringContainsString( + 'MAX(IF(product_status.value = ' . ProductStatus::STATUS_ENABLED . ', stock.is_salable, 0))', + $columns[IndexStructure::IS_SALABLE] + ); + self::assertArrayHasKey('product_status', $joinConditions); + self::assertStringContainsString( + 'product_status.store_id = ' . Store::DEFAULT_STORE_ID, + $joinConditions['product_status'] + ); + self::assertStringNotContainsString('product_status.value =', $joinConditions['product_status']); + } } From 46b77a706842c06334e443b5fd78732497391525 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Thu, 30 Jul 2026 10:11:51 -0400 Subject: [PATCH 2/2] perf(inventory-configurable-product): resolve configurable salability from the stock index --- .../Type/Configurable/IsSalablePlugin.php | 78 ++++++++ .../Type/Configurable/IsSalablePluginTest.php | 176 ++++++++++++++++++ .../etc/frontend/di.xml | 1 + 3 files changed, 255 insertions(+) create mode 100644 InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php create mode 100644 InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php diff --git a/InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php b/InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php new file mode 100644 index 00000000000..92fc5bf605d --- /dev/null +++ b/InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php @@ -0,0 +1,78 @@ +hasData('is_salable') || !$this->isCurrentStoreScope($subject, $product)) { + return (bool)$proceed($product); + } + } catch (\Throwable $exception) { + return (bool)$proceed($product); + } + + $salable = $product->getStatus() == Status::STATUS_ENABLED; + if ($salable) { + $salable = $product->getData('is_salable'); + } + + return (bool)(int)$salable; + } + + /** + * Whether the salability being asked for is the one of the current store. + * + * @param Configurable $subject + * @param ProductInterface $product + * @return bool + */ + private function isCurrentStoreScope(Configurable $subject, $product): bool + { + $storeFilter = $subject->getStoreFilter($product); + if ($storeFilter instanceof Store) { + $scopeStoreId = $storeFilter->getId(); + } elseif ($storeFilter !== null) { + $scopeStoreId = $storeFilter; + } else { + $scopeStoreId = $product->getStoreId(); + } + + if ($scopeStoreId === null || $scopeStoreId === '') { + return false; + } + + return (int)$scopeStoreId === (int)$this->storeManager->getStore()->getId(); + } +} diff --git a/InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php b/InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php new file mode 100644 index 00000000000..6cc1bf17015 --- /dev/null +++ b/InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php @@ -0,0 +1,176 @@ +storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->configurableMock = $this->createMock(Configurable::class); + + $storeMock = $this->createMock(StoreInterface::class); + $storeMock->method('getId')->willReturn(self::CURRENT_STORE_ID); + $this->storeManagerMock->method('getStore')->willReturn($storeMock); + + $this->plugin = new IsSalablePlugin($this->storeManagerMock); + } + + public function testLoadedIsSalableIsUsedWithoutTouchingTheCore(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + + $this->assertTrue($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testLoadedIsSalableZeroMakesProductNotSalable(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '0']); + + $this->assertFalse($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testLoadedIsSalableNullMakesProductNotSalable(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => null]); + + $this->assertFalse($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testDisabledProductIsNotSalable(): void + { + $product = $this->createProduct(['status' => Status::STATUS_DISABLED, 'is_salable' => '1']); + + $this->assertFalse($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testProductWithoutLoadedIsSalableIsDelegated(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED]); + + $this->assertTrue( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => true, $product) + ); + } + + public function testStoreFilterOfAnotherStoreIsDelegated(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $otherStore = $this->createMock(Store::class); + $otherStore->method('getId')->willReturn(7); + $this->configurableMock->method('getStoreFilter')->willReturn($otherStore); + + $this->assertFalse( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => false, $product) + ); + } + + public function testStoreFilterOfCurrentStoreKeepsTheFastPath(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $currentStore = $this->createMock(Store::class); + $currentStore->method('getId')->willReturn(self::CURRENT_STORE_ID); + $this->configurableMock->method('getStoreFilter')->willReturn($currentStore); + + $this->assertTrue($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testIntegerStoreFilterOfCurrentStoreKeepsTheFastPath(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $this->configurableMock->method('getStoreFilter')->willReturn(self::CURRENT_STORE_ID); + + $this->assertTrue($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testMissingScopeIsDelegated(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1'], null); + $this->configurableMock->method('getStoreFilter')->willReturn(null); + + $this->assertFalse( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => false, $product) + ); + } + + public function testStoreResolutionFailureFallsBackToTheCore(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $this->configurableMock->method('getStoreFilter') + ->willThrowException(new NoSuchEntityException(__('no store'))); + + $this->assertTrue( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => true, $product) + ); + } + + /** + * Build a product stub carrying the given data. + * + * @param array $data + * @param int|null $storeId + * @return Product|MockObject + */ + private function createProduct(array $data, ?int $storeId = self::CURRENT_STORE_ID) + { + $product = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->onlyMethods(['getStoreId', 'getSku', 'getStatus', 'hasData', 'getData']) + ->getMock(); + $product->method('getStoreId')->willReturn($storeId); + $product->method('getSku')->willReturn('sku-1'); + $product->method('getStatus')->willReturn($data['status']); + $product->method('hasData')->with('is_salable')->willReturn(array_key_exists('is_salable', $data)); + $product->method('getData')->with('is_salable')->willReturn($data['is_salable'] ?? null); + + return $product; + } + + /** + * A $proceed that must never be reached. + * + * @return callable + */ + private function failingProceed(): callable + { + return function () { + $this->fail('The core implementation must not be reached'); + }; + } +} diff --git a/InventoryConfigurableProduct/etc/frontend/di.xml b/InventoryConfigurableProduct/etc/frontend/di.xml index e2e71d3e3cd..233a1111cfd 100644 --- a/InventoryConfigurableProduct/etc/frontend/di.xml +++ b/InventoryConfigurableProduct/etc/frontend/di.xml @@ -11,6 +11,7 @@ +