--- 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]