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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ depends only on the core modules it plugs into.
`GraphQlCatalog/Model/ActiveFilters` keeps the filter of every products field by response key and
turns it into a selection over the salable variants; `Plugin/Resolver/OptionsSelectionFollowsFilters`
(sort order 20) hands core's resolver the uids. A uid the query passes wins for its attribute.
- `GraphQlCatalogInventory`: `ProductInterface.stock_sources`, the source items of the enabled sources
of the website's stock in priority order, from MSI's services in one batch per request. The
quantity is physical: reservations count on the stock only.
- `GraphQlCatalogSwatches`: the `swatch` field on aggregation, attribute and selected options.
`Model/Resolver/SwatchData` reads the swatch under the key and builds the thumbnail URL of a
stored image swatch; `Plugin/Output/*` and `Plugin/Layer/AggregationSwatches` fill the key from
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ reads the frontend input and the swatches of a filter from the listing query.
| `AttributeValueInterface.attribute`, `Aggregation.attribute` | `GraphQlCatalog` | The attribute metadata, as `customAttributeMetadataV2` returns it; null for a category aggregation |
| `swatch` on `AggregationOptionInterface`, `CustomAttributeOptionInterface`, `AttributeSelectedOptionInterface` | `GraphQlCatalogSwatches` | The `SwatchDataInterface` of an option of a swatch attribute, or null |
| `ConfigurableProduct.configurable_product_options_selection(followFilters)` | `GraphQlCatalogConfigurableProduct` | The selection the filter of the products query names: every super attribute the filter limits to one value, when a salable variant carries all of them |
| `ProductInterface.stock_sources` | `GraphQlCatalogInventory` | The physical quantity, status, pickup flag and location per enabled source of the website's stock |

The modules answer from core's resolvers and data providers and need nothing else.
[GraphCommerce Catalog Storefront](https://github.com/graphcommerce-org/magento2-GraphCommerce_CatalogStorefront)
Expand All @@ -21,13 +22,13 @@ requires this package and serves the same fields from its documents.
```sh
composer require graphcommerce/magento-graphql-catalog
bin/magento module:enable GraphCommerce_GraphQlCatalog GraphCommerce_GraphQlCatalogSwatches \
GraphCommerce_GraphQlCatalogConfigurableProduct
GraphCommerce_GraphQlCatalogConfigurableProduct GraphCommerce_GraphQlCatalogInventory
bin/magento setup:upgrade
```

`GraphCommerce_GraphQlCatalogSwatches` needs Magento_SwatchesGraphQl and
`GraphCommerce_GraphQlCatalogConfigurableProduct` needs Magento_ConfigurableProductGraphQl. A store
without them leaves the module disabled.
`GraphCommerce_GraphQlCatalogConfigurableProduct` needs Magento_ConfigurableProductGraphQl and
`GraphCommerce_GraphQlCatalogInventory` needs MSI. A store without them leaves the module disabled.

## Modules

Expand All @@ -36,6 +37,7 @@ without them leaves the module disabled.
| [`GraphQlCatalog`](src/GraphQlCatalog) | `custom_attributeV2`, the `attribute` of a value and an aggregation, the option swatch key |
| [`GraphQlCatalogSwatches`](src/GraphQlCatalogSwatches) | The `swatch` fields from core's swatch helper |
| [`GraphQlCatalogConfigurableProduct`](src/GraphQlCatalogConfigurableProduct) | `followFilters` on the configurable option selection |
| [`GraphQlCatalogInventory`](src/GraphQlCatalogInventory) | `stock_sources` from MSI's source items |

## Extension point

Expand Down
14 changes: 9 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "graphcommerce/magento-graphql-catalog",
"description": "Catalog GraphQL fields for Magento 2 / Mage-OS: one custom attribute by code, the attribute metadata of a value and an aggregation, the swatch of every option, and a configurable option selection that follows the listing filter. One package with every module; each module directory is a package of its own for a subtree split.",
"description": "Catalog GraphQL fields for Magento 2 / Mage-OS: one custom attribute by code, the attribute metadata of a value and an aggregation, the swatch of every option, a configurable option selection that follows the listing filter, and the quantity per stock source. One package with every module; each module directory is a package of its own for a subtree split.",
"type": "magento2-module",
"license": "MIT",
"require": {
Expand All @@ -14,22 +14,26 @@
"replace": {
"graphcommerce/module-graph-ql-catalog": "self.version",
"graphcommerce/module-graph-ql-catalog-swatches": "self.version",
"graphcommerce/module-graph-ql-catalog-configurable-product": "self.version"
"graphcommerce/module-graph-ql-catalog-configurable-product": "self.version",
"graphcommerce/module-graph-ql-catalog-inventory": "self.version"
},
"suggest": {
"magento/module-swatches-graph-ql": "GraphCommerce_GraphQlCatalogSwatches serves the swatch of an aggregation option, an attribute option and a selected option",
"magento/module-configurable-product-graph-ql": "GraphCommerce_GraphQlCatalogConfigurableProduct selects the configurable options the listing filter names"
"magento/module-configurable-product-graph-ql": "GraphCommerce_GraphQlCatalogConfigurableProduct selects the configurable options the listing filter names",
"magento/module-inventory-sales-api": "GraphCommerce_GraphQlCatalogInventory serves the quantity per source of the stock of the website"
},
"autoload": {
"files": [
"src/GraphQlCatalog/registration.php",
"src/GraphQlCatalogSwatches/registration.php",
"src/GraphQlCatalogConfigurableProduct/registration.php"
"src/GraphQlCatalogConfigurableProduct/registration.php",
"src/GraphQlCatalogInventory/registration.php"
],
"psr-4": {
"GraphCommerce\\GraphQlCatalog\\": "src/GraphQlCatalog/",
"GraphCommerce\\GraphQlCatalogSwatches\\": "src/GraphQlCatalogSwatches/",
"GraphCommerce\\GraphQlCatalogConfigurableProduct\\": "src/GraphQlCatalogConfigurableProduct/"
"GraphCommerce\\GraphQlCatalogConfigurableProduct\\": "src/GraphQlCatalogConfigurableProduct/",
"GraphCommerce\\GraphQlCatalogInventory\\": "src/GraphQlCatalogInventory/"
}
}
}
4 changes: 4 additions & 0 deletions dev/tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@
foreach ($package['autoload']['psr-4'] as $prefix => $path) {
$loader->addPsr4($prefix, dirname(__DIR__, 2) . '/' . $path, true);
}
// The interface the DI compile generates: declared here with core's shape when no generated code exists.
if (!interface_exists(\Magento\GraphQl\Model\Query\ContextExtensionInterface::class)) {
require __DIR__ . '/generated/ContextExtensionInterface.php';
}
17 changes: 17 additions & 0 deletions dev/tests/generated/ContextExtensionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
/**
* The extension interface Magento generates for the GraphQL context, for
* the unit tests of an installation without generated code.
*/
declare(strict_types=1);

namespace Magento\GraphQl\Model\Query;

interface ContextExtensionInterface extends \Magento\Framework\Api\ExtensionAttributesInterface
{
/** @return \Magento\Store\Api\Data\StoreInterface|null */
public function getStore();

/** @param \Magento\Store\Api\Data\StoreInterface $store */
public function setStore(\Magento\Store\Api\Data\StoreInterface $store);
}
77 changes: 77 additions & 0 deletions src/GraphQlCatalogInventory/Model/Resolver/StockSources.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\GraphQlCatalogInventory\Model\Resolver;

use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponse;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface;
use Magento\InventoryApi\Api\SourceItemRepositoryInterface;
use Magento\InventorySalesApi\Model\StockByWebsiteIdResolverInterface;

/**
* The source items of every product of the request in the enabled sources of the website's stock,
* read in one search.
*/
class StockSources implements BatchResolverInterface
{
public function __construct(
private readonly StockByWebsiteIdResolverInterface $stockByWebsiteId,
private readonly GetSourcesAssignedToStockOrderedByPriorityInterface $sourcesOfStock,
private readonly SourceItemRepositoryInterface $sourceItemRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
) {
}

public function resolve(ContextInterface $context, Field $field, array $requests): BatchResponse
{
$stockId = (int)$this->stockByWebsiteId->execute((int)$context->getExtensionAttributes()->getStore()->getWebsiteId())->getStockId();
$sources = [];
foreach ($this->sourcesOfStock->execute($stockId) as $source) {
if ($source->isEnabled()) {
$sources[$source->getSourceCode()] = $source;
}
}
$skus = array_values(array_unique(array_map(static fn($request) => (string)$request->getValue()['model']->getSku(), $requests)));
$items = [];
if ($sources && $skus) {
$criteria = $this->searchCriteriaBuilder
->addFilter(SourceItemInterface::SKU, $skus, 'in')
->addFilter(SourceItemInterface::SOURCE_CODE, array_keys($sources), 'in')
->create();
foreach ($this->sourceItemRepository->getList($criteria)->getItems() as $item) {
$items[$item->getSku()][$item->getSourceCode()] = $item;
}
}
$response = new BatchResponse();
foreach ($requests as $request) {
$productItems = $items[(string)$request->getValue()['model']->getSku()] ?? [];
$rows = [];
foreach ($sources as $code => $source) {
if (!isset($productItems[$code])) {
continue;
}
$extension = $source->getExtensionAttributes();
$rows[] = [
'source_code' => $code,
'name' => (string)$source->getName(),
'quantity' => (float)$productItems[$code]->getQuantity(),
'in_stock' => (int)$productItems[$code]->getStatus() === SourceItemInterface::STATUS_IN_STOCK,
'is_pickup_location' => $extension && method_exists($extension, 'getIsPickupLocationActive') && $extension->getIsPickupLocationActive(),
'latitude' => $source->getLatitude() === null ? null : (float)$source->getLatitude(),
'longitude' => $source->getLongitude() === null ? null : (float)$source->getLongitude(),
'postcode' => $source->getPostcode(),
'city' => $source->getCity(),
'country_code' => $source->getCountryId(),
];
}
$response->addResponse($request, $rows);
}

return $response;
}
}
10 changes: 10 additions & 0 deletions src/GraphQlCatalogInventory/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# GraphCommerce_GraphQlCatalogInventory

`ProductInterface.stock_sources`: the source items of the product in the enabled sources of the
stock of the website, in source priority order, with the source name, the physical quantity, the
source item status, the in-store pickup flag and the location. Reservations count on the stock
only, so `quantity` is the physical quantity, not the salable quantity. A product without source
items, such as a configurable product, answers an empty list.

[`Model/Resolver/StockSources`](Model/Resolver/StockSources.php) is a batch resolver: one source
item search for every product of the request.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\GraphQlCatalogInventory\Test\Unit\Model\Resolver;

use GraphCommerce\GraphQlCatalogInventory\Model\Resolver\StockSources;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\SearchCriteria;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\GraphQl\Model\Query\ContextInterface;
use Magento\GraphQl\Model\Query\ContextExtensionInterface;
use Magento\InventoryApi\Api\Data\SourceInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Api\Data\SourceItemSearchResultsInterface;
use Magento\InventoryApi\Api\Data\StockInterface;
use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface;
use Magento\InventoryApi\Api\SourceItemRepositoryInterface;
use Magento\InventorySalesApi\Model\StockByWebsiteIdResolverInterface;
use Magento\Store\Api\Data\StoreInterface;
use PHPUnit\Framework\TestCase;

class StockSourcesTest extends TestCase
{
public function testTheItemsOfTheEnabledSourcesInPriorityOrder(): void
{
$stock = $this->createStub(StockInterface::class);
$stock->method('getStockId')->willReturn(2);
$stockByWebsite = $this->createMock(StockByWebsiteIdResolverInterface::class);
$stockByWebsite->expects(self::once())->method('execute')->with(3)->willReturn($stock);
$sources = $this->createStub(GetSourcesAssignedToStockOrderedByPriorityInterface::class);
$sources->method('execute')->willReturn([$this->source('shop', true), $this->source('closed', false), $this->source('warehouse', true)]);
$results = $this->createStub(SourceItemSearchResultsInterface::class);
$results->method('getItems')->willReturn([$this->item('A', 'warehouse', 7, 1), $this->item('A', 'shop', 2, 0), $this->item('B', 'shop', 1, 1)]);
$repository = $this->createMock(SourceItemRepositoryInterface::class);
$repository->expects(self::once())->method('getList')->willReturn($results);
$criteria = $this->createStub(SearchCriteriaBuilder::class);
$criteria->method('addFilter')->willReturnSelf();
$criteria->method('create')->willReturn($this->createStub(SearchCriteria::class));

$requests = [$this->request('A'), $this->request('B'), $this->request('C')];
$response = (new StockSources($stockByWebsite, $sources, $repository, $criteria))->resolve($this->context(), $this->createStub(Field::class), $requests);

self::assertSame(['shop', 'warehouse'], array_column($response->findResponseFor($requests[0]), 'source_code'));
self::assertSame([2.0, 7.0], array_column($response->findResponseFor($requests[0]), 'quantity'));
self::assertSame([false, true], array_column($response->findResponseFor($requests[0]), 'in_stock'));
self::assertSame('Shop shop', $response->findResponseFor($requests[1])[0]['name']);
self::assertSame([], $response->findResponseFor($requests[2]));
}

private function source(string $code, bool $enabled): SourceInterface
{
$source = $this->createStub(SourceInterface::class);
$source->method('getSourceCode')->willReturn($code);
$source->method('isEnabled')->willReturn($enabled);
$source->method('getName')->willReturn('Shop ' . $code);
$source->method('getCountryId')->willReturn('NL');
return $source;
}

private function item(string $sku, string $source, float $quantity, int $status): SourceItemInterface
{
$item = $this->createStub(SourceItemInterface::class);
$item->method('getSku')->willReturn($sku);
$item->method('getSourceCode')->willReturn($source);
$item->method('getQuantity')->willReturn($quantity);
$item->method('getStatus')->willReturn($status);
return $item;
}

private function request(string $sku): BatchRequestItemInterface
{
$product = $this->createStub(Product::class);
$product->method('getSku')->willReturn($sku);
$request = $this->createStub(BatchRequestItemInterface::class);
$request->method('getValue')->willReturn(['model' => $product]);
return $request;
}

private function context(): ContextInterface
{
$store = $this->createStub(StoreInterface::class);
$store->method('getWebsiteId')->willReturn(3);
$extension = $this->createStub(ContextExtensionInterface::class);
$extension->method('getStore')->willReturn($store);
$context = $this->createStub(ContextInterface::class);
$context->method('getExtensionAttributes')->willReturn($extension);
return $context;
}
}
21 changes: 21 additions & 0 deletions src/GraphQlCatalogInventory/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "graphcommerce/module-graph-ql-catalog-inventory",
"description": "The physical quantity of a product per source of the website's stock in the catalog GraphQL schema, for any Magento store with MSI",
"type": "magento2-module",
"license": "MIT",
"require": {
"php": "~8.1.0|~8.2.0|~8.3.0|~8.4.0",
"magento/framework": "*",
"magento/module-catalog-graph-ql": "*",
"magento/module-inventory-api": "*",
"magento/module-inventory-sales-api": "*"
},
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"GraphCommerce\\GraphQlCatalogInventory\\": ""
}
}
}
10 changes: 10 additions & 0 deletions src/GraphQlCatalogInventory/etc/module.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="GraphCommerce_GraphQlCatalogInventory">
<sequence>
<module name="Magento_CatalogGraphQl"/>
<module name="Magento_InventoryApi"/>
<module name="Magento_InventorySalesApi"/>
</sequence>
</module>
</config>
16 changes: 16 additions & 0 deletions src/GraphQlCatalogInventory/etc/schema.graphqls
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface ProductInterface {
stock_sources: [ProductStockSource!]! @doc(description: "The quantity of the product in each enabled source of the stock of the website, in source priority order.") @resolver(class: "GraphCommerce\\GraphQlCatalogInventory\\Model\\Resolver\\StockSources")
}

type ProductStockSource @doc(description: "The physical quantity of a product in one source. Reservations count on the stock only, so the quantity is not the salable quantity.") {
source_code: String!
name: String!
quantity: Float!
in_stock: Boolean! @doc(description: "The source item status.")
is_pickup_location: Boolean! @doc(description: "The source is an active in-store pickup location.")
latitude: Float
longitude: Float
postcode: String
city: String
country_code: String
}
6 changes: 6 additions & 0 deletions src/GraphQlCatalogInventory/registration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(ComponentRegistrar::MODULE, 'GraphCommerce_GraphQlCatalogInventory', __DIR__);
Loading