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
andgetCollectionsField
. 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:
Returning a field that exists in the Query/Mutation root of the target GraphQL schema
Applying and/or passing down arguments for the query/mutation
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:
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
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:
The Query controller now extends the
TypedQuery
classNew member property
typeMap
is presentThe 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:
Adding new fields to GraphQL queries made by the Query controller
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
Was this helpful?