-
Notifications
You must be signed in to change notification settings - Fork 101
Annotated input type #269
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
Annotated input type #269
Conversation
Woah, this is a huge amount of work! Thanks so much! I'm a bit busy right now, but I'll definitely try to do an extensive code review by the end of the week. |
This looks amazing 👍 I wonder if we could add a "partial update" mechanism in the mix (see graphql-dotnet/conventions#128 (comment)). |
@moufmouf, thanks, looking forward to it. @gulien the partial update is possible with this feature. The idea is that input object (such as
When it's executed only
It won't work though if you property doesn't have a type. In this case such property has |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First of all, thanks very very much. This is by far the most extensive PR I received on GraphQLite and it is overall very good!
I wish I could spend more time reviewing this in depth but I really won't have time to seriously work on in before next Tuesday. That being said, here is a first review.
I really like the direction you are taking and I'm definitely looking forward to merging this when it is ready (and helping you working on it as time permits)
Regarding your concerns about breaking changes:
I was pretty sure I had applied a @unstable
annotation to all extensions point (https://graphqlite.thecodingmachine.io/docs/semver.html)
It seems I forgot to add it on RootTypeMapperInterface
:(
Yet, the documentation states:
As a rule of thumb:
If you are a GraphQLite user (using GraphQLite mainly through its annotations), we guarantee strict semantic versioning If you are extending GraphQLite features (if you are developing custom annotations, or if you are developing a GraphQlite integration with a framework...), be sure to check the tags.
Which is kind of a warning that if you are implementing GraphQLite interfaces, you should probably target a specific minor version of GraphQLite.
My gut feeling is that it is ok to introduce those breaking changes. Implementing RootTypeMapperInterface
if probably not that common.
I put other comments directly in the code.
I did not try yet to run your code :)
We will definitely have to add unit/integration tests (and documentation) before merging this, but this looks very very promising. Thanks a lot!
/** | ||
* @var string|null | ||
*/ | ||
private $inputType; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought about this and came to a decision that it would be more convenient to describe fields for both type and input within one annotation. You can apply @Input
and @Type
annotation for the same class and in most cases you'd want to mark the same properties as fields.
With two annotations you'd need to do this twice which is kind of redundant:
/**
* User entity.
*
* @GQL\Type()
* @GQL\Input()
*/
class User
{
/**
* @var string Username or login
*
* @GQL\Field()
* @GQL\InputField()
*/
private string $username;
/**
* @var string User email
*
* @GQL\Field()
* @GQL\InputField()
*/
private string $email;
/**
* @var DateTimeInterface|null Birthday
*
* @GQL\Field()
* @GQL\InputField()
*/
private ?DateTimeInterface $birthday = null;
}
With one @Field
annotation it looks more simple and clean:
/**
* User entity.
*
* @GQL\Type()
* @GQL\Input()
*/
class User
{
/**
* @var string Username or login
*
* @GQL\Field()
*/
private string $username;
/**
* @var string User email
*
* @GQL\Field()
*/
private string $email;
/**
* @var DateTimeInterface|null Birthday
*
* @GQL\Field()
*/
private ?DateTimeInterface $birthday = null;
}
You need to separate input fields from output you can use for
attribute.
But here it is the problem with inputType
and outputType
attributes. If I want to set the same type for both cases and the same field - which is 99% use case I think, I'd need to do this: @GQL\Field(inputType="Date", outputType="Date")
.
I considered alternative option - deprecate outputType
and add another attribute type
- which would suit for both input and output types. @GQL\Field(type="Date")
looks much better. What do you think about that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I understand your will to not repeat Field
and InputField
annotations. It makes sense.
I'm trying to assess if there are any corner-cases that might bother us in the future.
Let's imagine this:
/**
* User entity.
*
* @GQL\Type()
* @GQL\Input()
*/
class User
{
// ...
/**
* @var string Username or login
*
* @GQL\Field()
*/
private string $password;
public function setPassword(string $password): void {
$this->password = $password;
}
}
In this case, we have a @field annotation on the property, a setter, but no getter.
What should happen in your opinion? Should GraphQLite throw an error? (something like "Output field "password" is not reachable via a getter, please add a "getPassword" method). Or should we silently put only an input field and no output field?
(I'm trying to put as much attention as possible to all the error messages a user can have and to have meaningful error messages)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a good question.
Or should we silently put only an input field and no output field?
I don't think silently hiding fields is the way to go. At some point it might confuse developers, especially with models containing a lot of properties, why their field is not working even though there's a @Field
annotation.
I like the idea of throwing an error. At the moment if you try accessing such field PHP will throw an error like Cannot access private property ...
. It would be much better to display a message as you suggested to give more accurate explanation of the problem.
} | ||
|
||
$parameters[] = $values[$name] ?? $parameter->getDefaultValue(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since fields can be set in the constructor, wouldn't it make sense if the @Field
annotation could be declared in the constructor too? (the problem being that in @Field(for=...)
, the for is referring a typename right now and not a variable name (as in the rest of the application).
Also, something is coming to my mind: with the @Factory
annotation, you can use many additional annotations like @Autowire
, @UseInputType
, etc... since a constructor is a kind of default factory, wouldn't it make sense to reuse a part of the code used to instantiate objects declared with the @Factory
annotation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if we should overload constructor with such logic. It's job is only to instantiate an object with a given parameters. Current implementation assumes that required constructor parameters have defined fields on properties with the same name in the class. When object is instantiated users payload is mapped to the constructor parameters.
For example:
/**
* @GQL\Input()
*/
class User
{
/**
* @var string Username or login
*
* @GQL\Field()
*/
private string $username;
/**
* @param string $username
*/
public function __construct(string $username)
{
$this->username = $username;
}
}
In this case we definitely know that input type has a field called username
and it's required. There's a constructor parameter with the same name so when the object is instantiated value from the request payload will be passed to it. For this createInstance()
function is responsible - this is kind of default factory, which knows how to map data to a constructor and instantiate an object.
I agree that it would be nice to provide a custom and flexible way of creating instances before they are populated with new data. At the beginning I thought of reusing @Factory
. We could add a new attribute @Input(init="FactoryName")
. But after that I realized that it's not very flexible. For example it would be nice to reuse the same input type in different mutations, but pre-load should be done with different identificators. For example:
updateUser(id: ID!, user: UserInput!)
updateUserByEmail(email: String!, user: UserInput)
updateMe(user: UserInput)
Considering this we'd need to create 3 different input types with the same fields but different init
attributes.
As an alternative this init
attribute could be added to @UseInputType
annotation. This way we choose what type should be used and how the object should be initialized for each particular mutation. Also extra parameters such as "id: ID!" or "email: String!" described in factories could be mapped not to input types, but to mutations directly. I hope it won't be confusing. Here's an example of what I mean:
/**
* @GQL\Type()
* @GQL\Input()
*/
class User
{
/**
* @var string
*
* @GQL\Field(for="User")
*/
private string $id;
/**
* @var string
*
* @GQL\Field()
*/
private string $username;
...
}
...
class UserFactory
{
/**
* @Factory(name="LoadUser")
*
* @param string $id
*
* @return User
*/
public function loadUser(string $id): User {
// ... Load user from DB
}
}
...
class UserController
{
/**
* @Mutation(name="UpdateUser")
* @UseInputType(for="$user", inputType="UserInput!", init="LoadUser")
*
* @param User $user
*
* @return User
*/
public function updateUser(User $user): User {
// ... $user is loaded from DB and populated with given data. Just validate and save it.
}
}
Because @UseInputType
points to "LoadUser" factory with init
attribute, another field id
will be added to the mutation as loadUser()
function requires it as a parameter. So the request mapping will be like this:
updateUserByEmail(id: String!, user: UserInput)
My only concern is that it might be not obvious that by adding init
attribute your mutation signature is split across different methods - actual mutation method and supportive "init" factory. It might be a bit confusing or not obvious...
What do you think about this idea? Do you see other ways to achieve this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
About the "init" idea: I would even go one step further. The concept of @factory is hard to explain and I'm getting a lot of people confused with it.
Since quite often, a factory is used by only one mutation, we could even imagine something like this:
class UserController
{
/**
* @Mutation(name="UpdateUser")
* @UseInputType(for="$user", factory="UserFactory::loadUser", inputName="UpdateUserInput")
*
* @param User $user
*
* @return User
*/
public function updateUser(User $user): User {
// ... $user is loaded from DB and populated with given data. Just validate and save it.
}
}
and this might actually be easier to understand for many people.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is UserFactory
just some service with loadUser
function and has nothing to do with @Factory
annotation?
I considered similar approach, but in this case you'd need to specify an absolute class name. For example: @UseInputType(for="$user", factory="App\Services\UserFactory::loadUser", inputName="UpdateUserInput")
. It will be easier to understand and use. Not sure only if it looks good in the code though 😄
@moufmouf
I also think that it's be fine. For the ones who currently have this this interface implemented, it should be easy to spot that something is wrong, since PHP will throw the exception at compilation time. |
@devmaslov just a quick message to let you know I started applying some changes in your code (right now, it's only applying cs-fixes to have a green CI) Be sure to pull the code from your branch before working on it again! |
@devmaslov I started adding tests here: bb69621 It is just a simple test to see if @field annotation works correctly in properties. See: https://travis-ci.com/github/thecodingmachine/graphqlite/jobs/353951488#L361 |
@moufmouf Looks like this issue with inheritance appears when you property is defined as |
@moufmouf Fixed the problem. The reason for it was different, but still I noticed interesting behavior with private properties.
In this case field name appears in User type without any problems. But if you remove |
@devmaslov just a friendly ping to let you know that this looks awesome and can't wait to get it merged in :) |
@devmaslov @moufmouf any chance we can get this over the finish line soon before it becomes too stale? |
I'd like to move this forward as well. @moufmouf, what should be our next steps here? Is there anything that should be adjusted before we dive into covering all of this with unit tests? There're a couple open threads above ⬆️ |
@moufmouf it's done. CI is passing 🎉 |
Hey @moufmouf, |
Hey @devmaslov , I'm definitely not forgetting this PR, I've just been overwhelmed lately, but I'll try to have a proper look at it and validate it this week! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @devmaslov ,
The amount of work you put into this PR is astounding. The doc is perfect, unit tests are also great. I have nothing to add. Let's merge this! 👍
Also, I realize I have been very long to give feedback. I've had an awful lot of work lately (not directly related to GraphQLite alas) and I certainly need some help maintaining this library. Since this PR clearly shows you understood GraphQLite, would you like to be promoted as a maintainer of GraphQLite?
@devmaslov Say yes :) |
@oojacoboo the offer also extends to you ❤️ |
@moufmouf, @oojacoboo Thanks a lot for your input and feedback 😄 Happy to see this branch merged 🥳 |
Cool! |
@moufmouf no, I don't have one 🙂 |
@moufmouf I'm happy to continue to help out - discuss issue tickets, help with some pull requests, etc. I'm fairly familiar with some of the internals. We do use this lib pretty heavily and will be using it much more so going forward. |
@oojacoboo I'll take that for a yes :D |
Sorry to intrude here, really glad that the responsibility is now shared, thanking each of you. May I call attention to the graphqlite-bundle as well, I have a tiny humble PR waiting there (: |
@devmaslov this looks awesome! may i ask what is holding back the release of this? |
@Lappihuan we're mostly waiting for release instructions from @moufmouf |
Any movement on 4.2 / instructions @moufmouf ? |
The initial goal of this PR is to solve the issue about handling update without default values and with only properties passed by a user into the request. You can find my initial proposal here: #210.
This update brings the following features and changes:
@Input
is introduced on classes. It works the same way as@Type
, but for input types. In combination with@Field
annotation on properties you can describe input object type. When this input type is injected into a mutation during the request new instance of the class is created with only properties passed by a user. See examples below. The@input
annotation has the following attributes:name
: Input name. If it's omitted it will be generated based on the class name. For example for class nameUser
,UserInput
type name will be generated.default
: Determines whether this type is default. The same idea as in@Factory
annotation.description
: Just a description for documentation.update
: Determines if this input should be in "update" mode. For such input types all properties are optional and without default values.@Field
annotation now can be used on class properties Feature Request: Field for property #246. If property is private or protected setter (for input) or getter (for output) is required. The type is guessed from property type (>= php 7.4) or from PHPDoc@var
tag. Also description is extracted from PHPDoc and applied to the field. This annotation has a couple of new attributes:for
:@Field
annotation can be now applied several times to the same method or property. Propertyfor
allows to determine to which type it should belong to. For example:@Field(for="User")
or@Field(for={"CreateUserInput", "UpdateUserInput"})
description
: If you require a different description per different type for the same field, you can now use a new propertydescription
of@Field
annotation.inputType
: allows to set a specific input type on a field - similar as it works withoutputType
.@Security
,@Right
,@Logged
etc. can be used on properties alongside with@Field
.In the initial proposal there was an idea about pre-loading an entity on update, so it would be possible to inject into mutation a loaded from DB User for example with populated data from the request. I tried to follow the idea where you put
ID
(or another field) as first param andpayload
as the second one. For example:updateUser(id: ID!, user: UserInput!)
,updateUserByEmail(email: String!, user: UserInput)
orupdateMe(user: UserInput)
(for logged it user). Implementing this might be an overhead, because at least we need to dynamically add a property (if necessary) to a mutation based on which entity should be loaded and also loaders which would handle the pre-loading. I think this kind of thing can be done as a separate feature I'll gladly discuss any ideas about it. For now I suggest using this feature for describing DTOs as input types and inject them into mutations. Mutation can use a custom service or handle DTO to Entity transformation by themself.Here's an example how it all works together.
UserInput
- DTO for user entity with public fields. Totally the same can be achieved for private/protected fields with setters. Also you can omit it and describe inputs directly onUser
class.User
- actual entity.And mutations. Instance of
UserInput
will be created with populated data from a request and injected for "$user" argument. With@Assertion
annotation those inputs can be validated in Symfony.I must say that the code is not covered with unit tests yet. First I'd like to hear some opinions about the implementation and the concept in general.
Also there'are few not critical but still Breaking changes.
$refMethod
argument has been replaced without a type hint so it would be possible to pass eitherReflectionMethod
orReflectionProperty
in such public methods:AnnotationReader::getMiddlewareAnnotations
InvalidPrefetchMethodRuntimeException::methodNotFound
CannotMapTypeException::createForMissingPhpDoc
CachedDocBlockFactory::getDocBlock
RootTypeMapperInterface
methods and all implementing it classes - this is my biggest concern