Version 5 supported

Plugins

What are plugins?
An overview of how plugins work with the GraphQL schema
Writing a simple plugin
In this tutorial, we add a simple plugin for string fields
Writing a complex plugin
In this tutorial, we'll create a plugin that affects models, queries, and input types

Writing a complex plugin

For this example, we'll imagine that a lot of our DataObjects are geocoded, and this is ostensibly some kind of DataExtension that adds lat/lon information to the DataObject, and maybe allows you to ask how close it is to a given lat/lon pair.

We want any queries using these DataObjects to be able to search within a radius of a given lat/lon.

To do this, we'll need a few things:

  • DataObjects that are geocodable should always expose their lat/lon fields for GraphQL queries
  • read operations for DataObjects that are geocodable should include a within parameter
  • An input type for this lat/lon parameter should be globally available to the schema
  • A resolver should automatically filter the result set by proximity.

Let's get started.

Step 1: ensure DataObject models expose lat/lon fields

Since we're dealing with DataObject models, we'll need to implement a ModelTypePlugin.

namespace App\GraphQL\Plugin;

use App\Geo\GeocodableExtension;
use SilverStripe\GraphQL\Schema\Interfaces\ModelTypePlugin;
use SilverStripe\GraphQL\Schema\Schema;
use SilverStripe\GraphQL\Schema\Type\ModelType;
// ...

class GeocodableModelPlugin implements ModelTypePlugin
{
    public function getIdentifier(): string
    {
        return 'geocode';
    }

    public function apply(ModelType $type, Schema $schema, array $config = []): void
    {
        $class = $type->getModel()->getSourceClass();

        // sanity check that this is a DataObject
        Schema::invariant(
            is_subclass_of($class, DataObject::class),
            'The %s plugin can only be applied to types generated by %s models',
            __CLASS__,
            DataObject::class
        );

        // only apply the plugin to geocodable DataObjects
        if (!ViewableData::has_extension($class, GeocodableExtension::class)) {
            return;
        }

        $type->addField('Lat')
            ->addField('Lon');
    }
}

And register the plugin:

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\Schema\Registry\PluginRegistry:
    constructor:
      - 'App\GraphQL\Plugin\GeocodableModelPlugin'

Once we've applied the plugin, all DataObjects that have the GeocodableExtension extension will be forced to expose their lat/lon fields.

Step 2: add a new parameter to the queries

We want any readXXX query to include a within parameter if it's for a geocodable DataObject. For this, we're going to implement ModelQueryPlugin, because this is for queries generated by a model.

namespace App\GraphQL\Plugin;

// ...

class GeocodableQueryPlugin implements ModelQueryPlugin
{
    public function getIdentifier(): string
    {
        return 'geocodableQuery';
    }

    public function apply(ModelQuery $query, Schema $schema, array $config = []): void
    {
        $class = $query->getModel()->getSourceClass();
        // Only apply to geocodable objects
        if (!ViewableData::has_extension($class, GeocodableExtension::class)) {
            return;
        }

        $query->addArg('within', 'SearchRadiusInput');
    }
}

Register the new plugin

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\Schema\Registry\PluginRegistry:
    constructor:
      - 'App\GraphQL\Plugin\GeocodableModelPlugin'
      - 'App\GraphQL\Plugin\GeocodableQueryPlugin'

Now after we apply the plugins, our read queries will have a new parameter:

query readEvents(within: ...)

But we're not done yet! What is SearchRadiusInput? We haven't defined that yet. Ideally, we want our query to look like this:

query {
  readEvents(within: {
    lat: 123.123456,
    lon: 123.123456,
    proximity: 500,
    unit: METER
  }) {
    nodes {
      title
      lat
      lon
    }
  }
}

Step 3: adding an input type

We'll need this SearchRadiusInput to be shared across queries. It's not specific to any DataObject. For this, we can implement SchemaUpdater. For tidiness, let's just to this in the same GeocodeableQuery class, since they share concerns.

namespace App\GraphQL\Plugin;

use SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater;
use SilverStripe\GraphQL\Schema\Type\Enum;
use SilverStripe\GraphQL\Schema\Type\InputType;
// ...

class GeocodableQueryPlugin implements ModelQueryPlugin, SchemaUpdater
{
    // ...

    public static function updateSchema(Schema $schema): void
    {
        $unitType = Enum::create('Unit', [
            'METER' => 'METER',
            'KILOMETER' => 'KILOMETER',
            'FOOT' => 'FOOT',
            'MILE' => 'MILE',
        ]);
        $radiusType = InputType::create('SearchRadiusInput')
            ->setFields([
                'lat' => 'Float!',
                'lon' => 'Float!',
                'proximity' => 'Int!',
                'unit' => 'Unit!',
            ]);
        $schema->addType($unitType);
        $schema->addType($unitType);
    }
}

So now we can run queries with these parameters, but we need to somehow apply it to the result set.

Step 4: add a resolver to apply the filter

All these DataObjects have their own resolvers already, so we can't really get into those to change their functionality without a massive hack. This is where the idea of resolver middleware and resolver afterware comes in really useful.

Resolver middleware runs before the operation's assigned resolver Resolver afterware runs after the operation's assigned resolver

Middlewares and afterwares are pretty straightforward. They get the same $args, $context, and $info parameters as the assigned resolver, but the first argument, $result is mutated with each resolver.

In this case, we're going to be filtering our DataList procedurally and transforming it into an array. We need to know that things like filters and sort have already been applied, because they expect a DataList instance. So we'll need to do this fairly late in the chain. Afterware makes the most sense.

namespace App\GraphQL\Plugin;

use App\Geo\Proximity;
// ...

class GeocodableQueryPlugin implements ModelQueryPlugin, SchemaUpdater
{
    // ...

    public function apply(ModelQuery $query, Schema $schema, array $config = []): void
    {
        $class = $query->getModel()->getSourceClass();
        // Only apply to geocodable objects
        if (!ViewableData::has_extension($class, GeocodableExtension::class)) {
            return;
        }

        $query->addArg('within', 'SearchRadiusInput');
        $query->addResolverAfterware([static::class, 'applyRadius']);
    }

    public static function applyRadius($result, array $args): array
    {
        $results = [];
        $proximity = new Proximity($args['unit'], $args['lat'], $args['lon']);
        foreach ($result as $record) {
            if ($proximity->isWithin($args['proximity'], $record->Lat, $record->Lon)) {
                $results[] = $record;
            }
        }

        return $results;
    }
}

Looking good!

But there's still one little gotcha. This is likely to be run after pagination has been executed, so our $result parameter is probably an array of edges, nodes, etc.

// app/src/GraphQL/Resolver/MyResolver.php
namespace App\GraphQL\Resolver;

use App\Geo\Proximity;

class MyResolver
{
    public static function applyRadius($result, array $args)
    {
        $results = [];
        // imaginary class
        $proximity = new Proximity($args['unit'], $args['lat'], $args['lon']);
        foreach ($result['nodes'] as $record) {
            if ($proximity->isWithin($args['proximity'], $record->Lat, $record->Lon)) {
                $results[] = $record;
            }
        }

        return [
            'edges' => $results,
            'nodes' => $results,
            'pageInfo' => $result['pageInfo'],
        ];
    }
}

If we added this plugin in middleware rather than afterware, we could filter the result set by a list of IDs early on, which would allow us to keep a DataList throughout the whole cycle, but this would force us to loop over an unlimited result set, and that's never a good idea.

If you've picked up on the inconsistency that the pageInfo property is now inaccurate, this is a long-standing issue with doing post-query filters. Ideally, any middleware that filters a DataList should do it at the query level, but that's not always possible.

Step 5: apply the plugins

We can apply the plugins to queries and DataObjects one of two ways:

  • Add them on a case-by-case basis to our config
  • Add them as default plugins so that we never have to worry about it.

Let's look at each approach:

Case-by-case

# app/_graphql/models.yml
App\Model\Event:
  plugins:
    geocode: true
  fields:
    title: true
  operations:
    read:
      plugins:
        geocodeableQuery: true

This can get pretty verbose, so you might just want to register them as default plugins for all DataObjects and their read operations. In this case we've already added logic within the plugin itself to skip DataObjects that don't have the GeoCodable extension.

Apply by default

# apply the `DataObject` plugin
SilverStripe\GraphQL\Schema\DataObject\DataObjectModel:
  default_plugins:
    geocode: true
# apply the query plugin
SilverStripe\GraphQL\Schema\DataObject\ReadCreator:
  default_plugins:
    geocodableQuery: true

Further reading

What are plugins?
An overview of how plugins work with the GraphQL schema
Writing a simple plugin
In this tutorial, we add a simple plugin for string fields
Writing a complex plugin
In this tutorial, we'll create a plugin that affects models, queries, and input types