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 awithin
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 (!ModelData::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 (!ModelData::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 (!ModelData::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