mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-09-18 23:46:21 +02:00
296 lines
8.6 KiB
Markdown
296 lines
8.6 KiB
Markdown
---
|
|
title: Writing a complex plugin
|
|
summary: 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](https://www.silverstripe.org/blog/community-slack-channel/),
|
|
and report any issues at [github.com/silverstripe/silverstripe-graphql](https://github.com/silverstripe/silverstripe-graphql).
|
|
Docs for the current stable version (3.x) can be found
|
|
[here](https://github.com/silverstripe/silverstripe-graphql/tree/3)
|
|
[/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 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 DataObjects expose lat/lon fields
|
|
|
|
Since we're dealing with DataObjects, we'll need a `ModelTypePlugin`.
|
|
|
|
```php
|
|
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.
|
|
|
|
```php
|
|
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:
|
|
|
|
```graphql
|
|
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:
|
|
|
|
```graphql
|
|
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.
|
|
|
|
|
|
```php
|
|
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**
|
|
```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:
|
|
|
|
```yaml
|
|
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**
|
|
```yaml
|
|
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
|
|
|
|
```yaml
|
|
# 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]
|