diff --git a/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php index a8711545..d4248727 100644 --- a/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php +++ b/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php @@ -3,6 +3,7 @@ namespace SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter; +use GraphQL\Type\Definition\ResolveInfo; use SilverStripe\GraphQL\Schema\DataObject\FieldAccessor; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Type\Type; @@ -36,6 +37,7 @@ public function getIdentifier(): string } /** + * @param array $config * @return array */ protected function getResolver(array $config): array @@ -71,9 +73,14 @@ protected function updateInputBuilder(NestedInputBuilder $builder): void if (!$type instanceof ModelType) { return false; } + $dataObject = DataObject::singleton($type->getModel()->getSourceClass()); $fieldName = $field instanceof ModelField ? $field->getPropertyName() : $field->getName(); - return FieldAccessor::singleton()->hasNativeField($dataObject, $fieldName); + $isNative = FieldAccessor::singleton()->hasNativeField($dataObject, $fieldName); + + // If the field has its own resolver, then we'll allow anything it because the user is + // handling all the computation. + return $isNative || $field->getResolver(); }); } @@ -85,8 +92,9 @@ public static function filter(array $context) { $fieldName = $context['fieldName']; $rootType = $context['rootType']; + $resolvers = $context['resolvers'] ?? []; - return function (?Filterable $list, array $args, array $context) use ($fieldName, $rootType) { + return function (?Filterable $list, array $args, array $context, ResolveInfo $info) use ($fieldName, $rootType, $resolvers) { if ($list === null) { return null; } @@ -109,16 +117,29 @@ public static function filter(array $context) $fieldParts = explode('.', $path); $filterID = array_pop($fieldParts); $fieldPath = implode('.', $fieldParts); - + $filter = $registry->getFilterByIdentifier($filterID); + Schema::invariant( + $filter, + 'No registered filters match the identifier "%s". Did you register it with %s?', + $filterID, + FilterRegistryInterface::class + ); + if (isset($resolvers[$fieldPath])) { + $newContext = $context; + $newContext['filterComparator'] = $filterID; + $newContext['filterValue'] = $value; + $list = call_user_func_array($resolvers[$fieldPath], [$list, $args, $newContext, $info]); + continue; + } $normalised = $schemaContext->mapPath($rootType, $fieldPath); Schema::invariant( $normalised, - 'Plugin %s could not map path %s on %s', + 'Plugin %s could not map path %s on %s. If this is a custom filter field, make sure you included + a resolver.', static::IDENTIFIER, $fieldPath, $rootType ); - $filter = $registry->getFilterByIdentifier($filterID); if ($filter) { $list = $filter->apply($list, $normalised, $value); } diff --git a/src/Schema/Plugin/AbstractQueryFilterPlugin.php b/src/Schema/Plugin/AbstractQueryFilterPlugin.php index d775d236..204cb311 100644 --- a/src/Schema/Plugin/AbstractQueryFilterPlugin.php +++ b/src/Schema/Plugin/AbstractQueryFilterPlugin.php @@ -3,6 +3,7 @@ namespace SilverStripe\GraphQL\Schema\Plugin; +use Psr\Container\NotFoundExceptionInterface; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; @@ -18,7 +19,6 @@ use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\Services\NestedInputBuilder; use SilverStripe\GraphQL\Schema\Type\InputType; -use SilverStripe\GraphQL\Schema\Type\ModelType; use SilverStripe\GraphQL\Schema\Type\Type; /** @@ -47,6 +47,7 @@ protected function getFieldName(): string * Creates all the { eq: String, lte: String }, { eq: Int, lte: Int } etc types for comparisons * @param Schema $schema * @throws SchemaBuilderException + * @throws NotFoundExceptionInterface */ public static function updateSchema(Schema $schema): void { @@ -82,7 +83,8 @@ public static function updateSchema(Schema $schema): void public function apply(ModelQuery $query, Schema $schema, array $config = []): void { $fields = $config['fields'] ?? Schema::ALL; - $builder = NestedInputBuilder::create($query, $schema, $fields); + $resolvers = $config['resolve'] ?? []; + $builder = NestedInputBuilder::create($query, $schema, $fields, $resolvers); $this->updateInputBuilder($builder); $builder->populateSchema(); if (!$builder->getRootType()) { @@ -91,12 +93,18 @@ public function apply(ModelQuery $query, Schema $schema, array $config = []): vo $query->addArg($this->getFieldName(), $builder->getRootType()->getName()); $canonicalType = $schema->getCanonicalType($query->getNamedType()); $rootType = $canonicalType ? $canonicalType->getName() : $query->getNamedType(); + $resolvers = $builder->getResolvers(); + $context = [ + 'fieldName' => $this->getFieldName(), + 'rootType' => $rootType, + ]; + if (!empty($resolvers)) { + $context['resolvers'] = $resolvers; + } + $query->addResolverAfterware( $this->getResolver($config), - [ - 'fieldName' => $this->getFieldName(), - 'rootType' => $rootType, - ] + $context ); } diff --git a/src/Schema/Services/NestedInputBuilder.php b/src/Schema/Services/NestedInputBuilder.php index c28a187b..3f753479 100644 --- a/src/Schema/Services/NestedInputBuilder.php +++ b/src/Schema/Services/NestedInputBuilder.php @@ -69,13 +69,20 @@ class NestedInputBuilder */ private $rootType; + /** + * @var array + */ + private $resolveConfig; + /** * NestedInputBuilder constructor. * @param Field $root + * @param Schema $schema * @param string $fields + * @param array $resolveConfig * @throws SchemaBuilderException */ - public function __construct(Field $root, Schema $schema, $fields = Schema::ALL) + public function __construct(Field $root, Schema $schema, $fields = Schema::ALL, $resolveConfig = []) { $this->schema = $schema; $this->root = $root; @@ -87,6 +94,7 @@ public function __construct(Field $root, Schema $schema, $fields = Schema::ALL) ); $this->fields = $fields; + $this->setResolveConfig($resolveConfig); } /** @@ -106,6 +114,9 @@ public function populateSchema() if ($this->fields === Schema::ALL) { $this->fields = $this->buildAllFieldsConfig($type); + } elseif (isset($this->fields[Schema::ALL]) && $this->fields[Schema::ALL]) { + unset($this->fields[Schema::ALL]); + $this->fields = array_merge($this->fields, $this->buildAllFieldsConfig($type)); } $this->addInputTypesToSchema($type, $this->fields, null, null, $prefix); $rootTypeName = $prefix . $this->getTypeName($type); @@ -181,6 +192,9 @@ protected function addInputTypesToSchema( $inputType = InputType::create($inputTypeName); } foreach ($fields as $fieldName => $data) { + if ($fieldName === Schema::ALL) { + $this->buildAllFieldsConfig($type); + } if ($data === false) { continue; } @@ -197,13 +211,25 @@ protected function addInputTypesToSchema( if (!$fieldObj && $type instanceof ModelType) { $fieldObj = $type->getModel()->getField($fieldName); } + + $customResolver = $this->getResolver($fieldName); + $customType = $this->getResolveType($fieldName); + Schema::invariant( - $fieldObj, - 'Could not find field "%s" on type "%s"', + $fieldObj || ($customResolver && $customType), + 'Could not find field "%s" on type "%s". If it is a custom filter field, you will need to provide a + resolver function in the "resolver" config for that field along with an explicit type.', $fieldName, $type->getName() ); + if (!$fieldObj) { + $fieldObj = Field::create($fieldName, [ + 'type' => $customType, + 'resolver' => $customResolver, + ]); + } + if (!$this->shouldAddField($type, $fieldObj)) { continue; } @@ -281,6 +307,65 @@ public function getRootType(): ?InputType return $this->rootType; } + /** + * @param array $config + * @return $this + * @throws SchemaBuilderException + */ + public function setResolveConfig(array $config): self + { + foreach ($config as $fieldName => $data) { + Schema::invariant( + is_string($fieldName) && isset($data['resolver']) && isset($data['type']), + '"resolve" setting for nested input must be a map of field name keys to an array that contains + a "resolver" field and "type" key' + ); + } + + $this->resolveConfig = $config; + + return $this; + } + + /** + * @return array + */ + public function getResolveConfig(): array + { + return $this->resolveConfig; + } + + /** + * @param string $name + * @return string|array|null + */ + public function getResolver(string $name) + { + return $this->resolveConfig[$name]['resolver'] ?? null; + } + + /** + * @param string $name + * @return string|null + */ + public function getResolveType(string $name): ?string + { + return $this->resolveConfig[$name]['type'] ?? null; + } + + /** + * @return array + */ + public function getResolvers(): array + { + $resolvers = []; + foreach ($this->resolveConfig as $fieldName => $config) { + $resolvers[$fieldName] = $config['resolver']; + } + + return $resolvers; + } + /** * Public API that can be used by a resolver to flatten the input argument into * dot.separated.paths that can be normalised diff --git a/src/Schema/Storage/CodeGenerationStore.php b/src/Schema/Storage/CodeGenerationStore.php index ea73c72b..ae091861 100644 --- a/src/Schema/Storage/CodeGenerationStore.php +++ b/src/Schema/Storage/CodeGenerationStore.php @@ -99,6 +99,11 @@ class CodeGenerationStore implements SchemaStorageInterface */ private $obfuscator; + /** + * @var bool + */ + private $verbose = true; + /** * @param string $name * @param CacheInterface $cache @@ -277,21 +282,21 @@ public function persistSchema(StorableSchema $schema): void $logger->info(sprintf('Types built: %s', count($built))); $snapshot = array_slice($built, 0, 10); foreach ($snapshot as $type) { - $logger->output('*' . $type); + $logger->info('*' . $type); } $diff = count($built) - count($snapshot); if ($diff > 0) { - $logger->output(sprintf('(... and %s more)', $diff)); + $logger->info(sprintf('(... and %s more)', $diff)); } $logger->info(sprintf('Types deleted: %s', count($deleted))); $snapshot = array_slice($deleted, 0, 10); foreach ($snapshot as $type) { - $logger->output('*' . $type); + $logger->info('*' . $type); } $diff = count($deleted) - count($snapshot); if ($diff > 0) { - $logger->output(sprintf('(... and %s more)', $diff)); + $logger->info(sprintf('(... and %s more)', $diff)); } } @@ -427,6 +432,17 @@ public function setObfuscator(NameObfuscator $obfuscator): CodeGenerationStore return $this; } + /** + * If true, s + * @param bool $bool + * @return $this + */ + public function setVerbose(bool $bool): self + { + $this->verbose = $bool; + + return $this; + } /** * @return string diff --git a/tests/SilverStripe/GraphQL/Tests/Fake/Inheritance/A.php b/tests/Fake/Inheritance/A.php similarity index 100% rename from tests/SilverStripe/GraphQL/Tests/Fake/Inheritance/A.php rename to tests/Fake/Inheritance/A.php diff --git a/tests/Schema/IntegrationTest.php b/tests/Schema/IntegrationTest.php index a27d6bd3..af08b1f1 100644 --- a/tests/Schema/IntegrationTest.php +++ b/tests/Schema/IntegrationTest.php @@ -11,9 +11,11 @@ use SilverStripe\Dev\SapphireTest; use SilverStripe\GraphQL\QueryHandler\QueryHandler; use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; +use SilverStripe\GraphQL\Schema\Exception\EmptySchemaException; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Exception\SchemaNotFoundException; use SilverStripe\GraphQL\Schema\Field\Query; +use SilverStripe\GraphQL\Schema\Logger; use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\SchemaBuilder; use SilverStripe\GraphQL\Schema\Storage\CodeGenerationStore; @@ -1064,16 +1066,71 @@ public function provideObfuscationState(): array return [ [false], [true] ]; } + public function testCustomFilterFields() + { + $dir = '_' . __FUNCTION__; + + $dataObject1 = DataObjectFake::create(['MyField' => 'Atest1']); + $dataObject1->write(); + + $dataObject2 = DataObjectFake::create(['MyField' => 'Btest2']); + $dataObject2->write(); + + $id1 = $dataObject1->ID; + $id2 = $dataObject2->ID; + + $schema = $this->createSchema(new TestSchemaBuilder([$dir])); + + $query = <<querySchema($schema, $query); + $this->assertSuccess($result); + $this->assertResult('readOneDataObjectFake.id', $id1, $result); + + $query = <<querySchema($schema, $query); + $this->assertSuccess($result); + $this->assertResult('readOneDataObjectFake.id', $id2, $result); + + $query = <<querySchema($schema, $query); + $this->assertSuccess($result); + $this->assertResult('readOneDataObjectFake.id', $id1, $result); + } + /** * @param TestSchemaBuilder $factory * @return Schema * @throws SchemaBuilderException * @throws SchemaNotFoundException + * @throws EmptySchemaException */ private function createSchema(TestSchemaBuilder $factory): Schema { $this->clean(); $schema = $factory->boot(); + + /* @var Logger $logger */ + $logger = Injector::inst()->get(Logger::class); + $logger->setVerbosity(Logger::ERROR); + $factory->build($schema, true); return $schema; @@ -1182,4 +1239,21 @@ private function assertResult(string $path, $value, array $result) } } } + + public static function resolveCustomFilter($list, $args, $context) + { + $bool = $context['filterValue']; + $comp = $context['filterComparator']; + if ($comp === 'ne') { + $bool = !$bool; + } + + if ($bool) { + $list = $list->filter('MyField:StartsWith', 'A'); + } else { + $list = $list->exclude('MyField:StartsWith', 'A'); + } + + return $list; + } } diff --git a/tests/Schema/TestSchemaBuilder.php b/tests/Schema/TestSchemaBuilder.php index 521eb532..3b57ebfb 100644 --- a/tests/Schema/TestSchemaBuilder.php +++ b/tests/Schema/TestSchemaBuilder.php @@ -10,6 +10,7 @@ use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Exception\SchemaNotFoundException; use SilverStripe\GraphQL\Schema\Field\Query; +use SilverStripe\GraphQL\Schema\Logger; use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\SchemaBuilder; @@ -78,6 +79,10 @@ public function boot(string $key = 'test'): Schema */ private function bootstrapSchema(string $key) { + /* @var Logger $logger */ + $logger = Injector::inst()->get(Logger::class); + $logger->setVerbosity(Logger::ERROR); + Config::modify()->merge( Schema::class, 'schemas', diff --git a/tests/Schema/_testCustomFilterFields/models.yml b/tests/Schema/_testCustomFilterFields/models.yml new file mode 100644 index 00000000..0549f2f4 --- /dev/null +++ b/tests/Schema/_testCustomFilterFields/models.yml @@ -0,0 +1,15 @@ +SilverStripe\GraphQL\Tests\Fake\DataObjectFake: + operations: + readOne: + plugins: + filter: + fields: + '*': true + onlyStartsWithA: true + resolve: + onlyStartsWithA: + type: Boolean + resolver: ['SilverStripe\GraphQL\Tests\Schema\IntegrationTest', 'resolveCustomFilter'] + sort: true + fields: + myField: true