webonyx / graphql-php

PHP implementation of the GraphQL specification based on the reference implementation in JavaScript
https://webonyx.github.io/graphql-php
MIT License
4.64k stars 564 forks source link

How to register ScalarTypes? #681

Open brunobg opened 4 years ago

brunobg commented 4 years ago

How do I resolve extended ScalarTypes? https://webonyx.github.io/graphql-php/type-system/scalar-types/ explains how to create a class for them and the tests show how to ]build a Schema manually with them https://github.com/webonyx/graphql-php/blob/1f0ec2408010e01879b1f980afac65de2344c631/tests/Executor/VariablesTest.php#L132, but I can't find how to load them with a parser.

Concrete example, trying to parse this GraphQL:

scalar TestScalarType @scalar(class: "ModelariumTests\\TestScalarType")

schema {
  query: TestType
}

input TestInputObject {
  t: TestScalarType
}

type TestType {
  fieldWithObjectInput(input: TestInputObject): String
  fieldWithScalarInput(input: TestScalarType): TestScalarType
}

The idea is to use the directive to resolve the class. I'm parsing it like this:

class TestScalarType extends ScalarType { ... }

class MyTest {
    public function testObjectParserQuery()
    {
        $document = Parser::parse(
            'query q($input: TestInputObject) {
            fieldWithObjectInput(input: $input)
        }'
        );

        $data = file_get_contents('test.graphql'); // loads the schema above
        $ast = \GraphQL\Language\Parser::parse($data);
        $schemaBuilder = new \GraphQL\Utils\BuildSchema(
            $ast,
            [__CLASS__, 'extendDatatypes']
        );
        $schema = $schemaBuilder->buildSchema();

        $result = Executor::execute($schema, $document, null, null, ['input' => [ 't' => 'valid']]);

        $this->assertEquals([], $result->errors);
        $expected = [
            'data' => ['fieldWithObjectInput' => '{"t":"xvalid"}'],
        ];
        $this->assertEquals($expected, $result->toArray());
    }

extendDatatypes is called for TestType, but I don't see how to load the scalar classes dynamically. Any hints please?

spawnia commented 4 years ago

What you are trying to do is quite tricky and requires deeply modifying the schema build process. Check out https://github.com/nuwave/lighthouse/tree/master/src/Schema for an example of how this can be achieved.

This library contains the necessary primitives to handle directives and customize schema building, and i do not think it should do anything more opinionated.

brunobg commented 4 years ago

Since you have a type loader for buildschema, I was expecting the lib would do something for any node kind or a pointer of how to do it. For other people that one day may find themselves here, I solved it by parsing the AST normally, them modifying it using Visitor.

If I may suggest something, it would be interesting to have a callback for enter/leave like Visitor already in buildSchema.

spawnia commented 4 years ago

If I may suggest something, it would be interesting to have a callback for enter/leave like Visitor already in buildSchema.

Can you elaborate? What new use cases would this enable/how does it make existing use cases easier?

brunobg commented 4 years ago

Of course! The way I implemented the class loader I described above was more or less like this (and I am still struggling to find out how to replace the Type class on the fields):

$ast = \GraphQL\Language\Parser::parse($data);
Visitor::visit($this->ast, [
    NodeKind::SCALAR_TYPE_DEFINITION => function ($node) {
        // some code to read the directives and map the $node->name->value to my class
       return null;
    }
]);
$schemaBuilder = new \GraphQL\Utils\BuildSchema(ast);
$schema = $schemaBuilder->buildSchema();

A visitor is what I expected the callback from BuildSchema to do -- it's not clear to me from the docs what exactly it does, is it only called for types?

Again, the use cases is what is being done in the tests. You build a schema manually in the VariablesTest with the custom class:

https://github.com/webonyx/graphql-php/blob/1f0ec2408010e01879b1f980afac65de2344c631/tests/Executor/VariablesTest.php#L132

but from what I understood from your answer it's hard to do that from a graphql description. Lighthouse seems to reimplement the schema builder completely. If there was a Visitor-like callback for BuildSchema, I could have a callback that could easily customize the creation of the Schema object, replacing the generic Scalar type for a MyCustomClass().

Thank you for the interest and prompt replies.

vladar commented 4 years ago

I might have understood this incorrectly. But my understanding is that:

  1. You are trying to define your schema with SDL
  2. Define a class for your custom scalar type using a directive

Technically this should be probably done by allowing custom typeLoader for the buildSchema. But in the meantime I think there is an existing workaround - you can replace the typeLoader and use it to replace CustomScalarType with your custom class.

Something along the lines should probably work (didn't check though):

$ast = \GraphQL\Language\Parser::parse($sdl);
$schemaBuilder = new \GraphQL\Utils\BuildSchema($ast);
$schema = $schemaBuilder->buildSchema();

$originalTypeLoader = $schema->getConfig()->typeLoader;

$schema->getConfig()->typeLoader = function($typeName) use ($originalTypeLoader) {
    $type = $originalTypeLoader($typeName);
    if ($type instanceof \GraphQL\Type\Definition\CustomScalarType) {
        $directives = $type->astNode->directives;
        // Look for your `@scalar` directive in $directives and return your custom class instead
        $customClass = findMyScalarClassName($directives);
        return new $customClass($type->config);
    }
    return $type;
}

Obviously just a workaround but it should work?

brunobg commented 4 years ago

Thank you very much, it seems that will work perfectly.

brunobg commented 4 years ago

Ahn, @vladar, sorry for the endless pestering, but besides the bug from #683, it seems that this does not replace types in fields. For example:

scalar TestText @scalar(class: "ModelariumTests\\Laravel\\ScalarTestText")

type User {
  id: ID!
  name: String!
  description: TestText!
}

calls your typeLoader only with a ObjectType, and when I traverse the AST the FieldDefinition $type is still CustomScalarType.

Perhaps I'm missing something and it only lazy loads?

Could I use a SchemaExtender to do this, overriding extendCustomScalarType?

vladar commented 4 years ago

Good catch. typeLoader is only partially useful indeed.

Reopening since I think this is a typical use-case (providing custom scalar type class) so we should provide a way to do it in this library.

compwright commented 9 months ago

It took a heck of a lot of work getting here, but I managed to solve this using a $typeConfigDecorator:

use GraphQL\Language\AST\ScalarTypeDefinitionNode;
use GraphQL\Language\AST\TypeDefinitionNode;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

class ScalarDirectiveDecorator
{
    private ?ContainerInterface $container;

    private LoggerInterface $logger;

    public function __construct(?ContainerInterface $container = null, LoggerInterface $logger = null)
    {
        $this->container = $container;
        $this->logger = $logger ?? new NullLogger();
    }

    /**
     * @param array<string, mixed> $config
     * @param array<string, mixed> $typeDefinitionMap
     * 
     * @return array<string, mixed>
     */
    public function __invoke(array $config, TypeDefinitionNode $typeDefinition, array $typeDefinitionMap): array
    {
        // Attach the custom scalar handler methods defined in the @scalar(class) directive
        if ($typeDefinition instanceof ScalarTypeDefinitionNode) {
            $this->logger->debug('Inspecting scalar ' . $config['name']);

            foreach ($this->readDirectives($typeDefinition) as $directive => $args) {
                if ($directive === 'scalar' && isset($args['class'])) {
                    $this->logger->debug('Attaching custom scalar class ' . $args['class']);

                    $scalarInstance = $this->container
                        ? $this->container->get($args['class'])
                        : new $args['class'];

                    $config['serialize'] = [$scalarInstance, 'serialize'];
                    $config['parseValue'] = [$scalarInstance, 'parseValue'];
                    $config['parseLiteral'] = [$scalarInstance, 'parseLiteral'];
                }
            }
        }

        return $config;
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function readDirectives(ScalarTypeDefinitionNode $node): array
    {
        $directives = [];

        foreach ($node->directives as $directive) {
            $args = [];

            foreach ($directive->arguments as $arg) {
                $args[$arg->name->value] = $arg->value->value;
            }

            $directives[$directive->name->value] = $args;
        }

        return $directives;
    }
}

You'll need this in your schema:

# used to specify the desired class to execute for a custom scalar 
directive @scalar(class: String!) on SCALAR

# add the directive to each custom scalar
scalar Email @scalar(class: "Compwright\\GraphqlScalars\\Email")

Then when loading the schema:

BuildSchema::build($ast, new ScalarDirectiveDecorator());

If multiple decorators are needed to implement various other things, they could be pipelined together using something like https://github.com/thephpleague/pipeline.

An official way to do this without relying on directives and type decorators would be great!

P.S. this solution is included in https://github.com/compwright/graphql-php-scalars.