Extending an Existing Query

Add a field to products by re-declaring ProductInterface and pointing it at a resolver that reads the product from the parent value.

Syntaxinterface ProductInterface { my_field: String @resolver(class: "Vendor\\Module\\Model\\Resolver\\Product\\MyField") }

The products query is resolved by Magento\CatalogGraphQl\Model\Resolver\Products. You do not replace it or plug into it. You declare one extra field on ProductInterface — the GraphQL interface every product type implements — and write a resolver for that field alone.

Declaring it means writing interface ProductInterface { ... } again in your own schema.graphqls with only your field. Magento merges the declarations.

What the resolver receives

Your resolver's $value is the row the products resolver built for one product, and $value['model'] is the loaded Magento\Catalog\Model\Product. That key is the contract the whole catalog schema relies on. Check it with empty() and throw if it is missing, as core resolvers do.

Not requested, not run

GraphQL calls a field's resolver only when the client asks for that field. An expensive field therefore costs nothing when it is left out of the document; you do not need a toggle to keep it out of the response. If your field can do less work depending on which sub-fields were asked for, read $info.

Loading the data it needs

The product collection adds the attribute whose code matches the requested field name. When your field name differs from the attribute code, or the value is assembled from several attributes, register it in fieldToAttributeMap on Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\AttributeProcessor in etc/graphql/di.xml. Apply both with bin/magento cache:flush, plus setup:di:compile in production mode.

Example

Example · php
<?php
// app/code/Learning/Faq/Model/Resolver/Product/FaqEntries.php
declare(strict_types=1);

namespace Learning\Faq\Model\Resolver\Product;

use Learning\Faq\Api\FaqRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;

class FaqEntries implements ResolverInterface
{
    public function __construct(private readonly FaqRepositoryInterface $faqRepository)
    {
    }

    /**
     * @inheritDoc
     */
    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        if (empty($value['model'])) {
            throw new LocalizedException(__('"model" value should be specified'));
        }

        /** @var ProductInterface $product */
        $product = $value['model'];

        $items = [];
        foreach ($this->faqRepository->getBySku((string) $product->getSku()) as $entry) {
            $items[] = [
                'uid' => $entry->getUid(),
                'question' => $entry->getQuestion(),
                'answer' => $entry->getAnswer(),
            ];
        }

        return $items;
    }
}

When to use it

  • Attach module data such as reviews, FAQ entries or a delivery estimate to the products query.
  • Expose a custom EAV attribute under a different field name than its attribute code.
  • Return a computed label on every product type without touching Magento_CatalogGraphQl.
  • Keep a slow field out of the response by leaving it out of the client's query.

More examples

Declaring the field on the catalog's own interface

Reach for this when the data belongs to a product and the storefront wants it in the same request as the product. After saving, run bin/magento cache:clean config, then query the field. If the field is absent from the response, the config cache still holds the old schema; if it is present but null, the resolver ran and returned nothing.

Example · graphql
# app/code/Learning/Faq/etc/schema.graphqls

# Magento_CatalogGraphQl owns ProductInterface. Declaring it again with one
# field adds that field to every product type: simple, configurable, bundle.
interface ProductInterface {
    faq_entries: [FaqEntry]
    @doc(description: "FAQ entries attached to this product.")
    @resolver(class: "Learning\\Faq\\Model\\Resolver\\Product\\FaqEntries")
}

# A client that omits faq_entries never pays for it:
# { products(filter: { sku: { eq: "24-MB01" } }) {
#     items { sku name faq_entries { question } }
# } }

Telling the collection which attribute to load

By default the processor calls addAttributeToSelect() with the requested field name, so a field named exactly like its attribute needs no configuration at all. This map exists for the mismatches. The file lives under etc/graphql/ because the mapping only applies in the graphql area. Run bin/magento cache:flush, and setup:di:compile in production mode; without it the field resolves to null even though the attribute has a value.

Example · xml
<?xml version="1.0"?>
<!-- app/code/Learning/Faq/etc/graphql/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\CatalogGraphQl\Model\Resolver\Products\DataProvider\Product\CollectionProcessor\AttributeProcessor">
        <arguments>
            <argument name="fieldToAttributeMap" xsi:type="array">
                <!-- GraphQL field faq_heading is backed by attribute faq_title -->
                <item name="faq_heading" xsi:type="string">faq_title</item>
            </argument>
        </arguments>
    </type>
</config>

Asking for fields that only some product types have

Your field is on ProductInterface, so it is available on every product type. Type-specific fields are not, and asking for one directly is a validation error. Both fragment forms say "only when this row is a ConfigurableProduct". Recognise the inline form: it is what core documentation and PWA Studio queries use.

Example · graphql
# Shorthand: an inline fragment. No name, used once, right where it applies.
{
  products(filter: { sku: { eq: "MH01" } }) {
    items {
      sku
      faq_entries { question }
      ... on ConfigurableProduct {
        configurable_options { label }
      }
    }
  }
}

# Longhand: the same selection as a named fragment, declared once and spread.
# Identical result; worth the name only when you spread it more than once.
{
  products(filter: { sku: { eq: "MH01" } }) {
    items {
      sku
      faq_entries { question }
      ...ConfigurableBits
    }
  }
}

fragment ConfigurableBits on ConfigurableProduct {
  configurable_options { label }
}

Discussion

  • Be the first to comment on this lesson.