Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce StandardTypeRegistry #1426

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

ruudk
Copy link
Contributor

@ruudk ruudk commented Aug 14, 2023

Currently, types are referenced and cached statically. This is problematic when using multiple schema's that have different standard types that share the same memory. For example, when running them in un-isolated unit tests or when there is a long running PHP process that serves GraphQL requests.

To solve this problem, we introduce a StandardTypeRegistry interface with a DefaultTypeRegistry implementation. People are allowed to create their own registries by implementing the interface. Every Schema should be constructed with a typeRegistry that is an instance of StandardTypeRegistry. From there on, all types are queried from the registry. The registry will be responsible for caching the types to make sure subsequent calls to the same type will return the same instance.

Internally, all static calls to the standard types (Type::string(), Type::int(), Type::float(), Type::boolean(), Type::id()) have been replaced with dynamic calls on the type registry. Also calls to the introspection objects and internal directives are using the type registry now.

As most people probably have only one schema, we keep the static methods on the Type call. These now forward to DefaultTypeRegistry::getInstance(). This way, we don't break existing installations.

The reason for creating a StandardTypeRegistry interface as opposed to just a non-final implementation is that it allows people to use composition instead of inheritance to extend the functionality of the registry. For example, in my project I'd like to have a registry that holds all types and that allows me to query any type by their name (instead of FQCN). I can then delegate the interface methods to the decorated StandardTypeRegistry.

Resolves #1424

@ruudk
Copy link
Contributor Author

ruudk commented Aug 14, 2023

It's outside the scope of this PR, but in the future, we could also ship 2 interfaces that can be implemented on the DefaultTypeRegistry:

/**
 * A registry that lazily initializes types by their class name.
 */
interface LazyInitializedFullyQualifiedTypeRegistry
{
    /**
     * @template TType of Type&NamedType
     *
     * @param class-string<TType> $type
     *
     * @return (callable(): TType)|TType
     */
    public function byClass(string $type);
}
/**
 * A registry that returns types by their name.
 */
interface NamedTypeRegistry
{
    /** @return Type&NamedType */
    public function byName(string $name);
}

That would also allow us to deprecate/remove the typeLoader as this can be now implemented using the type registry.

Copy link
Collaborator

@spawnia spawnia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not get through all files just yet, this is just some immediate feedback I can give you right now.

src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/StandardTypeRegistry.php Show resolved Hide resolved
src/Type/SchemaConfig.php Outdated Show resolved Hide resolved
src/Utils/BuildClientSchema.php Outdated Show resolved Hide resolved
src/Type/Definition/Type.php Outdated Show resolved Hide resolved
src/Type/Registry/DefaultStandardTypeRegistry.php Outdated Show resolved Hide resolved
src/Type/Registry/StandardTypeRegistry.php Show resolved Hide resolved
src/Utils/SchemaExtender.php Outdated Show resolved Hide resolved
src/Validator/Rules/QuerySecurityRule.php Outdated Show resolved Hide resolved
tests/DefaultStandardTypeRegistryTest.php Outdated Show resolved Hide resolved
Currently, types are referenced and cached statically. This is problematic when using multiple
schema's that have different standard types that share the same memory. For example, when running
them in un-isolated unit tests or when there is a long running PHP process that serves GraphQL
requests.

To solve this problem, we introduce a `StandardTypeRegistry` interface with a `DefaultTypeRegistry`
implementation. People are allowed to create their own registries by implementing the interface.
Every Schema should be constructed with a `typeRegistry` that is an instance of
`StandardTypeRegistry`. From there on, all types are queried from the registry. The registry will
be responsible for caching the types to make sure subsequent calls to the same type will return the
same instance.

Internally, all static calls to the standard types (Type::string(), Type::int(), Type::float(),
Type::boolean(), Type::id()) have been replaced with dynamic calls on the type registry. Also calls
to the introspection objects and internal directives are using the type registry now.

As most people probably have only one schema, we keep the static methods on the Type call. These now
forward to `DefaultTypeRegistry::getInstance()`. This way, we don't break existing installations.

The reason for creating a `StandardTypeRegistry` interface as opposed to just a non-final
implementation is that it allows people to use composition instead of inheritance to extend the
functionality of the registry. For example, in my project I'd like to have a registry that holds
all types and that allows me to query any type by their name (instead of FQCN). I can then delegate
the interface methods to the decorated `StandardTypeRegistry`.

Resolves webonyx#1424
@ruudk ruudk mentioned this pull request Aug 15, 2023
@ruudk
Copy link
Contributor Author

ruudk commented Aug 15, 2023

I removed the Introspection from the StandardTypeRegistry interface. It's now stored separately on the Schema and passed along the schema. Still not convinced. I think the Introspection class is weird.

💡 I have an idea: Maybe, we can solve all these problems, by creating something completely different than what this PR does:

NamedTypeRegistry

This is a registry, that holds type's (or callable's to the type) by their name.

Something like this:

<?php declare(strict_types=1);

include __DIR__ . '/vendor/autoload.php';

use GraphQL\Error\InvariantViolation;
use GraphQL\Type\Definition\BooleanType;
use GraphQL\Type\Definition\FloatType;
use GraphQL\Type\Definition\IDType;
use GraphQL\Type\Definition\IntType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Type\Definition\StringType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Introspection;

class CustomIntType extends IntType {}

class NamedTypeRegistry
{
    /** @var array<string, Type|(callable(): Type) */
    protected array $types;

    /** @param array<string, Type|(callable(): Type)> $types */
    public function __construct(array $types = [])
    {
        $standardTypes = [
            Type::INT => fn () => new IntType(),
            Type::FLOAT => fn () => new FloatType(),
            Type::BOOLEAN => fn () => new BooleanType(),
            Type::STRING => fn () => new StringType(),
            Type::ID => fn () => new IDType(),
        ];

        $introspectionTypes = [
            Introspection::SCHEMA_OBJECT_NAME => [Introspection::class, '_schema'],
            Introspection::TYPE_OBJECT_NAME => [Introspection::class, '_type'],
            Introspection::DIRECTIVE_OBJECT_NAME => [Introspection::class, '_directive'],
            Introspection::FIELD_OBJECT_NAME => [Introspection::class, '_field'],
            Introspection::INPUT_VALUE_OBJECT_NAME => [Introspection::class, '_inputValue'],
            Introspection::ENUM_VALUE_OBJECT_NAME => [Introspection::class, '_enumValue'],
            Introspection::TYPE_KIND_ENUM_NAME => [Introspection::class, '_typeKind'],
            Introspection::DIRECTIVE_LOCATION_ENUM_NAME => [Introspection::class, '_directiveLocation'],
        ];

        $this->types = array_merge(
            $standardTypes,
            $introspectionTypes,
            $types,
        );
    }

    /** @return Type|(callable(): Type) */
    public function byName(string $name)
    {
        if (! isset($this->types[$name])) {
            throw new \Exception(sprintf('Type "%s" not found', $name));
        }

        $type = $this->types[$name];
        if (is_callable($type)) {
            $this->types[$name] = $type = $type($this);
        }

        return $type;
    }

    /** @throws InvariantViolation */
    public function int(): ScalarType
    {
        return $this->byName(Type::INT);
    }

    /** @throws InvariantViolation */
    public function float(): ScalarType
    {
        return $this->byName(Type::FLOAT);
    }

    /** @throws InvariantViolation */
    public function string(): ScalarType
    {
        return $this->byName(Type::STRING);
    }

    /** @throws InvariantViolation */
    public function boolean(): ScalarType
    {
        return $this->byName(Type::BOOLEAN);
    }

    /** @throws InvariantViolation */
    public function id(): ScalarType
    {
        return $this->byName(Type::ID);
    }
}

$mutationType = fn (NamedTypeRegistry $registry) => new ObjectType([
    'name' => 'Mutation',
    'fields' => [
        'sum' => [
            'type' => $registry->int(),
            'args' => [
                'x' => ['type' => $registry->int()],
                'y' => ['type' => $registry->int()],
            ],
            'resolve' => static fn ($calc, array $args): int => $args['x'] + $args['y'],
        ],
    ],
]);

$registry = new NamedTypeRegistry([
    'Int' => fn () => new CustomIntType(),
    'Query' => fn (NamedTypeRegistry $registry) => new ObjectType([
        'name' => 'Query',
        'fields' => [
            'echo' => [
                'type' => $registry->string(),
                'args' => [
                    'message' => ['type' => $registry->string()],
                ],
                'resolve' => static fn ($rootValue, array $args): string => $rootValue['prefix'] . $args['message'],
            ],
        ],
    ]),
    'Mutation' => $mutationType
]);

assert($registry->string() === $registry->string());
assert(get_class($registry->string()) === StringType::class);

assert($registry->int() === $registry->int());
assert(get_class($registry->int()) === CustomIntType::class);

$introspectionTypeObject = $registry->byName(Introspection::TYPE_OBJECT_NAME);
assert($registry->string() === $introspectionTypeObject->getField('name')->getType());

assert($registry->string() === $registry->byName('Query')->getField('echo')->getType());
assert($registry->int() === $registry->byName('Mutation')->getField('sum')->getType());

echo 'All good.' . PHP_EOL;

The NamedTypeRegistry is an object, that holds all the (default) types of the schema. It can also be used to store additional types for the developer. But not required.

It initializes with default scalars, and introspection types.

The constructor can override any of the default scalar types and introspection types (not sure why one would want that but ok).

The Introspection types are lazy, and get a reference to the NamedTypeRegistry. Upon use, they will request the named scalar types. In fact, every lazy type, gets the NamedTypeRegistry as first argument to the callback. This solves the problem of "how to get the type registry".

This means we can remove the Type::overrideStandardTypes completely. The Type::string() methods etc can also be removed, as people should reference on the NamedTypeRegistry.

For the field definitions that are currently stored on the Introspection type, we do the same. We make them static, and require a NamedTypeRegistry as argument. Then, the Schema queries them once, but caches them inside the Schema.

We rename Introspection class to IntrospectionTypeBuilder.

To construct a Schema, one now needs to always pass a NamedTypeRegistry instance.

The example above, is a working example.

@@ -467,7 +466,7 @@ protected function shouldIncludeNode(SelectionNode $node): bool
$variableValues = $this->exeContext->variableValues;

$skip = Values::getDirectiveValues(
Directive::skipDirective(),
$this->exeContext->schema->typeRegistry->skipDirective(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still feels off, why would the $typeRegistry deal with directives?

src/Type/Registry/StandardTypeRegistry.php Show resolved Hide resolved
@@ -99,6 +107,9 @@ public function __construct($config)
$this->extensionASTNodes = $config->extensionASTNodes;

$this->config = $config;

$this->typeRegistry = $config->typeRegistry ?? DefaultStandardTypeRegistry::instance();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, the names $typeRegistry and DefaultStandardTypeRegistry do not accurately reflect that the expected object also deals with directives. I guess it makes sense for those interfaces to travel together, given that the directives functionality requires the built-in types.

Overall, I think we can get rid of the term standard types, it is never used in the GraphQL specification. Instead, it refers to built-in scalars and built-in directives. Both types and directives are considered a TypeSystemDefinition, or perhaps short definition. So how about we recombine the interfaces and call it BuiltInDefinitionRegistry, implement it as DefaultBuiltInDefinitionRegistry and refer to it as builtInDefinitionRegistry?

@ruudk
Copy link
Contributor Author

ruudk commented Aug 16, 2023

@spawnia what do you think about #1426 (comment)

@spawnia
Copy link
Collaborator

spawnia commented Aug 16, 2023

@spawnia what do you think about #1426 (comment)

Sounds interesting, perhaps you can illustrate that approach in a second pull request and allow us to compare?

@ruudk
Copy link
Contributor Author

ruudk commented Aug 16, 2023

@spawnia Sounds good. Will work on it!

@compwright
Copy link

💡 I have an idea: Maybe, we can solve all these problems, by creating something completely different than what this PR does:

NamedTypeRegistry

This is a registry, that holds type's (or callable's to the type) by their name.

I could really use something like this, what's the status @ruudk?

@TomHAnderson
Copy link
Contributor

I'm commenting from an implementation perspective and not an internal one. Why isn't this a container? Am I just viewing it too shallow?

This is where I store my types and were there a standard type registry I suppose I would implement against it:
https://github.com/API-Skeletons/doctrine-orm-graphql/blob/10.2.x/src/Type/TypeContainer.php

This is nearly bare-bones PSR-11 and is "standard type" and not introspective. I use many containers and allow overriding of their registry. That way my application can use a shared type manager if I create two drivers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Replace static variables and statically referenced types with instance based TypeRegistry
4 participants