-
Notifications
You must be signed in to change notification settings - Fork 222
Description
Q | A |
---|---|
Bug report? | no |
Feature request? | yes |
BC Break report? | no |
RFC? | no |
Version/Branch | 0.14 |
@overblog/graphql
I strongly believe that we should rework all builders in the bundle and here I'll try to explain why.
This is just a draft with my thoughts on how builders could be changed. Use examples as a starting point to come to a common conclusion of how field/fields/args builders should be used/changed/enhaced.
What is wrong with builders?
-
Currently all builders are working with raw arrays and simply return them. Working with PHP arrays is not the best experience, so I suggest we create new builder classes to get rid of using arrays. This would lead to less errors, more readable code, autosuggestions by IDEs + opens perspectives for some other features in the future, for example using one builder inside another. Examples will be shown below a little later.
-
The configuration flow is pretty messy right now. Take a look at the following example:
User: type: object config: fields: rawId: # Field builder builder: "RawId" builderConfig: name: photoID type: String # Args builder argsBuilder: builder: "Pager" config: defaultLimit: 50
We have
builder
,builderConfig
,argsBuilder
, anotherbuilder
one level deeper andconfig
, which actually should be also namedbuilderConfig
. This is not the most readable type configuration and should be also reworked. -
Lack of flexibility when it comes to the question Which part of my GraphQL Type do I want to be generated by a builder? We have 3 types of builders: args, field and fields builders. So what is the difference? Take a look at this picture:
As you can see the main difference between builders is that they are responsible for different parts of GraphQL types.
Important is that some builders are supersets of other builders, which means, that they can do the same as their subsets, for example we can replace multiple field and args builders with one fields builder, becase field and args builders are its subsets.Fields Builder > Field Builder > Args Builder
The problem here is, that a user can't use one builder in which he could decide what he wants to be generated. Instead he needs to choose between 3 predefined builders with static limited responsibility (green, blue and red areas on the pic). Maybe we can combine all 3 builders into 1 (Type Builder)?
These are 3 points on why we should rework builders.
1. Getting rid of arrays
FieldBuilder
Current implementation:
The method gets a single argument with all configs and returns an array.
class RawIdField implements MappingInterface
{
public function toMappingDefinition(array $config): array
{
$name = $config['name'] ?? 'ID';
$type = $config['type'] ?? 'Int!';
return [
'description' => 'The raw ID of an object',
'type' => $type,
'resolve' => "@=value.$name",
];
}
}
Suggestion:
The method gets all configs separately and doesn't return anything. The class extends a base class instead of implementing an interface.
class RawIdField extends FieldBuilder
{
public function build(string $name, string $type): void
{
$this
->setType($type)
->setDescription('The raw ID of an object')
->setResolverExpression("value.$name");
# if without expression
$this->setResolver('my_resolver');
}
}
Adding validation:
Passing constraint objects to the builder is not possible:
$this->setConstraints([
new Assert\Uuid()
new Assert\Length(['min' => 1, 'max' => 15]),
]);
because eventually a type class will be generated where the constraints will be actually instantiated. So we should pass class names instead of objects and optionally their params one by one:
$this->addConstraint(Uuid::class);
$this->addConstraint(Length::class, ['min' => 1, 'max' => 15]);
There might be different approaches to add constraints all at once:
- Passing array pairs
->setConstraints([
[Uuid::class]
[Length::class, ['min' => 1, 'max' => 15]],
]);
- Using an associative array, which has it's drawback, that constraints cannot be added more than once:
->setConstraints([
Uuid::class => null,
Length::class => ['min' => 1, 'max' => 15],
]);
This is a pretty rare case when someone needs to add a constraint more than once, but in case it's required it could be done by using addConstraint()
.
Entries without params could omit null
:
->setConstraints([
Uuid::class,
Length::class => ['min' => 1, 'max' => 15],
]);
Example:
class RawIdField extends FieldBuilder
{
public function build(): void
{
$this
->setType(Type::ID)
->isNullable(false)
->setResolveExpression("value.$name")
->setConstraints([
Assert\Uuid::class,
Assert\Length::class => ['min' => 1, 'max' => 15],
]);
# or set a link
$this->setLinkToProperty(User::class, 'id');
# or set cascade validation
$this->setCascade(true);
}
}
Args Builder
Pretty much the same as field builder.
Current Implementation:
class Pager implements MappingInterface
{
public function toMappingDefinition(array $config): array
{
$defaultLimit = isset($config['defaultLimit']) ? (int)$config['defaultLimit'] : 20;
return [
'limit' => [
'type' => 'Int!',
'defaultValue' => $defaultLimit,
],
'offset' => [
'type' => 'Int!',
'defaultValue' => 0,
],
];
}
}
Suggestion:
class Pager extends ArgumentsBuilder
{
public function build(int $defaultLimit = 20): void
{
$limit = $this->addArgument('limit', Type::INT, $defaultLimit)->isNullable(true);
$offset = $this->addArgument('offset', 'Int!', 0);
}
}
Some extra examples:
class Pager extends ArgumentsBuilder
{
public function build(): void
{
// ...
# Change some options
$limit->isNullable(false);
$limit->setType(Type::STRING);
# Add validation
$limit->addConstraint(Length::class, ['min' => 1, 'max' => 10]);
$this->removeArgument($offset);
# or
$this->removeArgument('offset');
}
}
Fields Builder
The same approach as in field and args builders: each part of the type should be buildable with predefined methods.
2. Changing the configuration
I don't have any idea to drastically change the current approach to insert builders into GraphQL types. It just feels somehow messy. Here is an example using all 3 builders:
User:
type: object
config:
# Fields builder
builders:
- builder: Timestamped
builderConfig:
propertyCreated: dateCreated
fields:
rawId:
# Field builder
builder: "RawId"
builderConfig:
name: photoID
type: String
# Args builder
argsBuilder:
builder: "Pager"
config:
defaultLimit: 50
The first thing I don't like about this approach is mixing of concepts of yaml and php configurations. The configuration gets sparsed, because part of the type lies in a yaml file and another part in a php file.
The second thing I don't like is that builders can be defined at many places, which looks kind of messy and confusing.
Maybe we can fix both problems with a Type Builder (new builder type), which I will talk about later.
And even if we don't change the approach above, we could at least make it a little bit nicer. Here is the same example changed:
User:
type: object
config:
# Fields builder
builders:
- name: Timestamped
params: [dateCreated]
fields:
rawId:
# Field builder
builder:
name: "RawId"
params: [photoId, String]
# Args builder
argsBuilder:
name: "Pager"
params: [50]
builderConfig
andconfig
are renamed toparams
and moved under thebuilder
option, becausebuilderConfig
makes no sense withoutbuilder
.params
can be defined without names (collection) and they will be injected into builder separately. If user wants to use named params he can do it and in this case params will be injected into the builder using same argument names in the PHP.
short syntax (without params):
User:
type: object
config:
builders: [Timestamped]
fields:
rawId:
builder: RawId
argsBuilder: Pager
Btw I don't know if field builder and args builder can be mixed.
3. Type Builder
A Type Builder is the new type of builders and it's responsible for generating of any part of a type:
Here is an example:
class UserBuilder extends TypeBuilder
{
public function build(string $name, string $type, ?string $resolver = null): void
{
$this->setName($name);
$this->setType($type);
$usernameField = $this->addField("username", "String!")->setResolver($resolver);
if("User" === $type) {
$usernameField->addArgument('limit', 'Int');
}
// ...
}
public function configProvider()
{
yield ['User', 'object', 'user_resolver'];
yield ['UserCreateInput', 'input-object'];
yield ['UserUpdateInput', 'input-object'];
yield ['UserPayload', 'object'];
}
}
This builder is used to create 4 different GraphQL types. The build
method will be called 4 times with different arguments received from configProvider
(like in PHPUnit tests). Technically a single type builder would be enough to build the entire GraphQL schema, but it would make sense to create separate builders for groups of related types.
With this we don't need yaml types at all, the entire schema could be defined with type builders. It could even be possible to use arg and field builders inside the type builder (don't know if it could be useful).
Maybe with type builders the need in other builders will disappear?