Introduce StandardTypeRegistry
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
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.
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.
@spawnia what do you think about https://github.com/webonyx/graphql-php/pull/1426#issuecomment-1678562672
@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?
@spawnia Sounds good. Will work on it!
💡 I have an idea: Maybe, we can solve all these problems, by creating something completely different than what this PR does:
NamedTypeRegistryThis 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?
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.