Query Controller

Query controller is a convenient way to manage GraphQL. This architectural solution helps to easily create, update, and extend GraphQL queries with the Extension Mechanism.

Motivation

We wanted an easy way to define fields for GraphQL queries that won't require writing query string, but will rather compose that string automatically, given the supplied data. This would allow for better readability, reusability, and most importantly - extensibility. We wanted the Query Controller to:

  • Make the process of query declarations more intuitive and simple,

  • Be modular to allow for extensibility,

  • Give the option to define query fields conditionally,

  • Support main GraphQL features, such as aliases and arguments

Usage

Query controllers look and behave just like a regular Javascript classes:

export class CollectionsQuery {
    _getCollectionFields() {
        return [
            'description',
            'descriptionHtml',
            'handle',
            'title',
            new Field('image').addFieldList([
                new Field('transformedSrc').setAlias('src'),
                new Field('altText').setAlias('alt')
            ])
        ];
    }

    _getCollectionField() {
        return new Field('node').addFieldList(
            this._getCollectionFields()
        );
    }

    _getEdgesField() {
        return new Field('edges').addFieldList([
            'cursor',
            this._getCollectionField()
        ]);
    }

    _getCollectionsFields() {
        return [
            this._getEdgesField(),
            getPageInfoField()
        ];
    }

    getCollectionsField({ first, after, before }) {
        return new Field('collections')
            .addFieldList(this._getCollectionsFields())
            .addArgument('before', 'String', before)
            .addArgument('after', 'String', after)
            .addArgument('first', 'Int', first);
    }

    getCollectionByHandleField({ handle }) {
        return new Field('collectionByHandle')
            .addArgument('handle', 'String!', handle)
            .addFieldList(this._getCollectionFields());
    }
}

export default new CollectionsQuery();

You can immediately spot three things:

  • Public methods to get fields for specfic query: getCollectionByHandleField and getCollectionsField. We call them Query getters.

  • "Private" methods prefixed with underscore to define the fields themselves: _getCollectionFields, _getCollectionField, _getEdgesField, and _getCollectionsFields. We call them Field getters.

  • Field that is used in every single one of these methods.

Field

Field is our way of storing GraphQL field data. It's a regular Javascript class and you can read more on Field as well as other GraphQL features supported by @scandipwa/graphql in the documentation.

Query getters

Query getters are used to compose a GraphQL query (or mutation) from given arguments, if any. Query getters are only responsible for:

  1. Returning a field that exists in the Query/Mutation root of the target GraphQL schema

  2. Applying and/or passing down arguments for the query/mutation

  3. Referencing the fields declared by Field getters

Query getters are not responsible for any field definitions! You shouldn't list the fields directly in Query getters and instead use Field getters to retrieve the desired fields.

✅ How does a good query getter look like:

getCollectionByHandleField({ handle }) {
    return new Field('collectionByHandle') // Returns a field from Query root - ✅ 
        .addArgument('handle', 'String!', handle) // Applies arguments - ✅ 
        .addFieldList(this._getCollectionFields()); // Field getter reference - ✅ 
}

How does a bad query getter look like:

getCollectionByHandleField({ handle }) {
    return new Field('collectionByHandle') // Returns a field from Query root - ✅ 
        .addArgument('handle', 'String!', handle) // Applies arguments - ✅ 
        .addFieldList(['description', 'title']); // Declares fields directly - ❌ 
}

Field getters

Field getters are used to define the fields that will be used by controller's queries, they are never called directly in the app. Field getters should be:

  1. Modular to allow for extensibility. Meaning that if there are multiple GraphQL types involved in the queries, each of them should be described in a separate private method

  2. Returning a field or an array of fields. No fields from the Query/Mutation root are permitted.

✅ How does a good field getter look like:

_getEdgesField() {
    return new Field('edges').addFieldList([ // Returns a field - ✅
        'cursor',
        this._getCollectionField() // References other Field getters - ✅
    ]);
}

How does a bad field getter look like:

_getEdgesField() {
    const edgesField = new Field('edges').addFieldList([ // Returns a field - ✅
        'cursor',
        this._getCollectionField() // References other Field getters - ✅
    ]);
    return new Field('collections') // Returns a field from Query root - ❌
        .addFieldList(edgesField); // Declares reusable fields - ❌
}

TypedQuery

TypedQuery allows to map Query getters to a function and offers more deep extension possiblities. This how a Query controller with TypedQuery looks like:

import { mapQueryToType, TypedQuery } from '@scandipwa/shopify-api/src/util/TypedQuery';

export const PAGINATED_COLLECTIONS = 'paginated';
export const SINGLE_COLLECTION = 'single';

export class CollectionsQuery extends TypedQuery {
    typeMap = {
        [PAGINATED_COLLECTIONS]: this.getCollectionsField.bind(this),
        [SINGLE_COLLECTION]: this.getCollectionByHandleField.bind(this)
    };

    _getCollectionFields() { ... }

    _getCollectionField() { ... }

    _getEdgesField() { ... }

    _getCollectionsFields() { ... }

    getCollectionsField({ first, after, before }) {
        return new Field('collections')
            .addFieldList(this._getCollectionsFields())
            .addArgument('before', 'String', before)
            .addArgument('after', 'String', after)
            .addArgument('first', 'Int', first);
    }

    getCollectionByHandleField({ handle }) {
        return new Field('collectionByHandle')
            .addArgument('handle', 'String!', handle)
            .addFieldList(this._getCollectionFields());
    }
}

export default mapQueryToType(CollectionsQuery);

There are several differences from a regular Query controller definition:

  1. The Query controller now extends the TypedQuery class

  2. New member property typeMap is present

  3. The default export is no longer a class instance, but the mapQueryToType function.

Query types

The usage of TypedQuery involves the assignment of type to each Query getter method. In the example above, we map getCollectionsField to the PAGINATED_COLLECTIONS type and getCollectionByHandleField to the SINGLE_COLLECTION type:

typeMap = {
    [PAGINATED_COLLECTIONS]: this.getCollectionsField.bind(this),
    [SINGLE_COLLECTION]: this.getCollectionByHandleField.bind(this)
    // ^ Query type ^                 ^ Query getter ^
};

In addition to typeMap, TypedQuery class defines this.currentType property, which will contain the type of the Query getter. If the query of SINGLE_COLLECTION type was called, this.currentType will be 'single'.

The knowledge of query type allows to conditionally add or remove fields from Field getters depending on the query, let's take product images as an example. We would want product page to contain 15 product images, but we would also want product listing page to only show one image. Following all the rules for creating Query controllers we would get:

_getImagesField() {
    return new Field('images')
        .addArgument('first', 'Int', 15) // Number of images to fetch
        .addField(new Field('edges')
            .addField(new Field('node')
                .addFieldList(this._getImagesFields())));
}

This leads to an obvious problem, as we would fetch 15 images for each product on the product listing page. We would be fetching unnecessary information and making our requests slower. This is a great place to implement TypedQuery:

_getImagesField() {
    const SINGLE_PRODUCTS_IMAGES = 15;
    const PAGINATED_PRODUCTS_IMAGES = 1;

    // Value of "first" depends on the query type
    const first = this.currentType === SINGLE_PRODUCT
        ? SINGLE_PRODUCTS_IMAGES // One product was queried = 15 images
        : PAGINATED_PRODUCTS_IMAGES; // Multiple products were queried = 1 image

    return new Field('images')
        .addArgument('first', 'Int', first) // number of images to fetch
        .addField(new Field('edges')
            .addField(new Field('node')
                .addFieldList(this._getImagesFields())));
}

Now that we have access to this.currentType, it is possible to conditionally change the number of images that we want to fetch based on the Query getter. In this example, we would fetch 15 images for single product query and only 1 for paginated product query.

Getting query by type

As mentioned before, Query controllers that extend TypedQuery must mapQueryToType(MyQuery) as a default export. In order to get a query by type, you will to call call the exported function as pass the query type as an argument:

const getCollectionQueryByType, { SINGLE_COLLECTION } from '@scandipwa/shopify-collections/src/api/Collections.query';

const queryGetter = getCollectionQueryByType(SINGLE_COLLECTION);

Extension

Why would you extend?

You would want to extend Query controllers for various reasons:

  1. Adding new fields to GraphQL queries made by the Query controller

  2. Defining brand new GraphQL query type

Examples

Learn how to extend Query controllers with these examples taken directly from the code.

Add new fields to a Field getter

import ProductTagsQuery from '../api/ProductTags.query';

const addTagsField = (args, callback) => {
    const originalProductFields = callback(...args);

    return [
        ...originalProductFields,
        'tags'
    ];
};

export default {
    'ShopifyProducts/Api/Products/Query/ProductsQuery': {
        'member-function': {
            _getProductFields: addTagsField
        }
    }
};

Add a new query type to typeMap

import ProductRecommendationsQuery from '../api/ProductRecommendations.query';

export const PRODUCT_RECOMMENDATIONS = 'product_recommendations';

const addProductRecommendationsQuery = (member, instance) => ({
    ...member,
    [PRODUCT_RECOMMENDATIONS]: ProductRecommendationsQuery.getRecommendedProductsField.bind(instance)
});

export default {
    'ShopifyProducts/Api/Products/Query/ProductsQuery': {
        'member-property': {
            typeMap: addProductRecommendationsQuery
        }
    }
};

Last updated