8.6 KiB
title | summary |
---|---|
Writing a complex plugin | In this tutorial, we'll create a plugin that affects models, queries, and input types |
Plugins
[CHILDREN asList]
[alert] You are viewing docs for a pre-release version of silverstripe/graphql (4.x). Help us improve it by joining #graphql on the Community Slack, and report any issues at github.com/silverstripe/silverstripe-graphql. Docs for the current stable version (3.x) can be found here [/alert]
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
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 DataObjects expose lat/lon fields
Since we're dealing with DataObjects, we'll need a ModelTypePlugin
.
class GeocodableDataObject 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 (!Extensible::has_extension($class, Geocodable::class)) {
return;
}
$type->addField('Lat')
->addField('Lon');
}
}
Now all dataobjects that have the 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 use ModelQueryPlugin
, because this is for queries generated by a model.
class GeocodableQuery 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 (!Extensible::has_extension($class, Geocodable::class)) {
return;
}
$query->addArg('within', 'SearchRadiusInput');
}
}
Now 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 use `SchemaUpdater`. For tidiness, let's just to this in the same `GeocodeableQuery`
class, since they share concerns.
```php
class GeocodableQuery implements ModelQueryPlugin, SchemUpdater
{
//...
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, transforming it into an array,
so we need to know that things like filters and sort have already been applied, as they expect a DataList
instance. So we'll need to do this fairly late in the chain. Afterware makes the most sense.
public function apply(ModelQuery $query, Schema $schema, array $config = []): void
{
$class = $query->getModel()->getSourceClass();
// Only apply to geocodable objects
if (!Extensible::has_extension($class, Geocodable::class)) {
return;
}
$query->addArg('within', 'SearchRadiusInput');
$query->addResolverAfterware([static::class, 'applyRadius']);
}
public static function applyRadius($result, array $args): array
{
$results = [];
// imaginary class
$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/Resolvers/MyResolver.php
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.
[notice]
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.
[/notice]
Step 5: Register the plugins
Back to Injector:
SilverStripe\Core\Injector\Injector:
SilverStripe\GraphQL\Schema\Registry\PluginRegistry:
constructor:
- 'MyProject\Plugins\GeocodableQuery'
- 'MyProject\Plugins\GeocodableDataObject'
Step 6: 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
MyProject\Models\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.
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
[CHILDREN]