-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[DI] Add compiler pass to check arguments type hint #27825
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
Conversation
2cc0647
to
fbe56a4
Compare
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.
Looks really nice, here are some comments to fine tune the PR.
I think we should now wonder how this should be used.
My preference would be to find a way to use this pass in a single dedicated test case, but I'm not sure how hard this would be to achieve. WDYT?
@@ -5,6 +5,7 @@ CHANGELOG | |||
----- | |||
|
|||
* added `ServiceSubscriberTrait` | |||
* added CheckTypeHintsPass to check injected parameters type during compilation |
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.
missing backticks around CheckTypeHintsPass
{ | ||
private $autoload; | ||
|
||
public function __construct(bool $autoload = false) |
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.
we could have a sentence telling what $autoload is useful for
} | ||
|
||
// When service is declared with $container->register(Foo::class); and not $container->register(Foo::class, Foo::class); | ||
if (null === $value->getClass()) { |
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.
not sure we need this case: when autoload is false, the bellow if will catch it, and when autoload is true, this pass should be registered after all the other passes, so that this situation won't exist then
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.
removed it and all tests still pass
foreach ($value->getMethodCalls() as $methodCall) { | ||
$reflectionMethod = $this->getReflectionMethod($value, $methodCall[0]); | ||
|
||
$this->checkArgumentsTypeHints($reflectionMethod /* warn */, $methodCall[1]); |
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.
warn?
/** | ||
* Check type hints for every parameter of a method/constructor. | ||
* | ||
* @param \ReflectionMethod $reflectionMethod |
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.
should be removed: we don't add annotations when they just duplicate the signature (same everywhere in the PR)
* | ||
* @param mixed $value | ||
* | ||
* @return string |
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.
should be replaced by a real return type
} elseif (null === $class) { | ||
$class = $value->getClass(); | ||
} elseif ($class instanceof Definition) { | ||
$class = $class->getClass(); |
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.
$class = $this->getClassName($class);
$class = $class->getClass(); | ||
} | ||
} else { | ||
$class = is_string($factory) ? null : $value->getClass(); |
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.
should we remove the is_string check?
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.
Unit tests say yes, so removed it
if (is_array($factory = $value->getFactory())) { | ||
list($class, $method) = $factory; | ||
if ($class instanceof Reference) { | ||
$class = $this->container->findDefinition((string) $class)->getClass(); |
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.
my mistake I think: here we're looking for the class of a referenced definition - not sure the class of its factory should be used for the validation. just the class of the definition, factory or not, should be more correct. WDYT?
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 about this part as only one test enters inside the if (is_array($factory = $value->getFactory())) {
, so I'd say factories uses cases are not enough covered
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.
For me too we should always return $value->getClass()
, we only need to handle factories for the constructor and it's done by getConstructor
. Since getClassName
is used to check whether a definition implements the right type hint, it should return the definition class.
return parent::processValue($value, $isRoot); | ||
} | ||
|
||
if (!$this->autoload && !\class_exists($this->getClassName($value), false)) { |
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.
interface_exists also on the definition's class (same for the other class_exists below)
there is a case where we load code here: when there is factory, the class checked here can be different than the class loaded below when loading methods, isn't it? (TDD should tell :) )
Also I think the autowired cases are not covered. I tested my pass in a symfony skeleton, and this exception is thrown: if (count($configurationArguments) < $numberOfRequiredParameters) {
throw new InvalidArgumentException(sprintf(
'Invalid definition for service "%s": "%s::%s()" requires %d arguments, %d passed.', $this->currentId, $reflectionMethod->class, $reflectionMethod->name, $numberOfRequiredParameters, count($configurationArguments)));
} I needed to register the $container->addCompilerPass(new AutowirePass());
$container->addCompilerPass(new CheckTypeHintsPass()); I think I'll add a notice about this, in exemple in pass docblock. Is there a better way to declare the dependency to another pass ? |
I think the pass can be integrated in the skeleton, in dev environment as error messages are clearer: why can now be aware of which service is badly configured. Or, maybe we could create a command like Fun fact: if we integrate the pass in skeleton, we have at least one type hint to fix:
|
83d05b8
to
702aa51
Compare
$container->addCompilerPass(new CheckTypeHintsPass(), PassConfig::TYPE_AFTER_REMOVING); Doing this works better, however I'm getting another error I can't reproduce in DependencyInjection component: I'll maybe catch this when working on functional tests in
|
b0fec89
to
9aae7a2
Compare
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.
Great addition, should it also check factories return type?
return parent::processValue($value, $isRoot); | ||
} | ||
|
||
if (!$this->autoload && !class_exists($this->getClassName($value), false)) { |
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.
As @nicolas-grekas said, there should also be an interface_exists
check.
} | ||
|
||
foreach ($value->getMethodCalls() as $methodCall) { | ||
$reflectionMethod = $this->getReflectionMethod($value, $methodCall[0]); |
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.
In case an interface is used, the real implementation may have methods the interface doesn't have. Should we support this case and not throw an exception for unexistent methods for objects not instantiated by sf (factories and shared services at least)?
This looks really great! Thanks @alcalyn. When this PR is finished, and just before merging it, could we please profile a real Symfony app to verify that the performance has not degraded significantly? Thanks! |
Hello @javiereguiluz , Thanks to you ! For now we are not planning to run this pass on every compilation, but on demand, in a phpunit test or with a command. But yes this sounds great, we should measure the cost of this pass to evaluate if it makes sense to register it for dev environment. |
Something like |
|
||
/** | ||
* Checks whether injected parameters types are compatible with type hints. | ||
* This pass should be added before removing (PassConfig::TYPE_BEFORE_REMOVING). |
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.
Shouldn't that happen after removing passes have been executed to ignore invalid definitions?
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 think no because it removes also user's unused service that may have bad type argument. Then it won't display the type hint error, whereas I expect all my services are checked, even if not (yet) used in my application.
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.
IMO that's something we can live with. My reasoning here is that we could otherwise end up with errors for third-party bundles we don't control which is probably a worse experience than having to fix your own config at a later time when your service is actually used. If your own removed services are not checked, that's not a big deal IMO as they won't be used at runtime anyway.
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, this is a different point of view which is also good. I'd say before removing is still relevant as it could be used in continuous quality to detect errors in services definitions before they're used. I'll update the comment to say that both before and after can be used, and explain differences.
d701691
to
3b5d4b2
Compare
3b5d4b2
to
3e9c37d
Compare
3e9c37d
to
cc8c4de
Compare
4f02c27
to
ddba549
Compare
Hi, I have not enough in in August as I have holidays and a talk to prepare. I'd have some time to work on this as of September... |
@alcalyn Do you think you will have time to work on this PR in the near future? |
Yes, I restarted to work on this PR last week, but I'm still stuck on few errors I get when running the pass with |
$numberOfRequiredParameters = $reflectionFunction->getNumberOfRequiredParameters(); | ||
|
||
if (count($configurationArguments) < $numberOfRequiredParameters) { | ||
throw new InvalidArgumentException(sprintf( |
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.
This implementation only shows an error if not enough parameters are given.
Wouldn't it be nice to see where too many parameters are given. Even though the compiler will not throw an error; a warning would be nice. This, for example, may occur if someone changes the signature of the constructor without changing the service definition...
This may prevent issues on the long term as someone may re-add a parameter to the signature, what will then break all implementations.
return; | ||
} | ||
|
||
if ('Traversable' === $parameter->getType()->getName() && $configurationArgument instanceof IteratorArgument) { |
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.
Personally, I do not like this, as it seems that iterable and Traversable seems to be case sensitive.
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.
iterable
does seem to be case insensitive as it's a php builtin type, however Traversable is case sensitive indeed.
return; | ||
} | ||
|
||
$checkFunction = 'is_'.$parameter->getType()->getName(); |
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.
sprintf('is_%s', $parameter->getType()->getName())
can be used here. Looks more neat than appending
Continued in #32256, thanks for working on this. |
…ces wiring matches type declarations (alcalyn, GuilhemN, nicolas-grekas) This PR was merged into the 4.4 branch. Discussion ---------- [DI] Add compiler pass and command to check that services wiring matches type declarations | Q | A | ------------- | --- | Branch? | 4.4 | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #27744 | License | MIT | Doc PR | PR replacing #27825. It adds a `lint:container` command asserting the type hints used in your code are correct. Commits ------- 8230a15 Make it really work on real apps 4b3e9d4 Fix comments, improve the feature a6292b9 [DI] Add compiler pass to check arguments type hint
…ces wiring matches type declarations (alcalyn, GuilhemN, nicolas-grekas) This PR was merged into the 4.4 branch. Discussion ---------- [DI] Add compiler pass and command to check that services wiring matches type declarations | Q | A | ------------- | --- | Branch? | 4.4 | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #27744 | License | MIT | Doc PR | PR replacing symfony/symfony#27825. It adds a `lint:container` command asserting the type hints used in your code are correct. Commits ------- 8230a1543e Make it really work on real apps 4b3e9d4c96 Fix comments, improve the feature a6292b917b [DI] Add compiler pass to check arguments type hint
…ces wiring matches type declarations (alcalyn, GuilhemN, nicolas-grekas) This PR was merged into the 4.4 branch. Discussion ---------- [DI] Add compiler pass and command to check that services wiring matches type declarations | Q | A | ------------- | --- | Branch? | 4.4 | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #27744 | License | MIT | Doc PR | PR replacing symfony/symfony#27825. It adds a `lint:container` command asserting the type hints used in your code are correct. Commits ------- 8230a1543e Make it really work on real apps 4b3e9d4c96 Fix comments, improve the feature a6292b917b [DI] Add compiler pass to check arguments type hint
…ces wiring matches type declarations (alcalyn, GuilhemN, nicolas-grekas) This PR was merged into the 4.4 branch. Discussion ---------- [DI] Add compiler pass and command to check that services wiring matches type declarations | Q | A | ------------- | --- | Branch? | 4.4 | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #27744 | License | MIT | Doc PR | PR replacing symfony/symfony#27825. It adds a `lint:container` command asserting the type hints used in your code are correct. Commits ------- 8230a1543e Make it really work on real apps 4b3e9d4c96 Fix comments, improve the feature a6292b917b [DI] Add compiler pass to check arguments type hint
Actually, when you inject a service to another through constructor/method/factory, if the type hint is invalid, you get an error at runtime only.
This PR adds a pass to check type hints during compilation.
See RFC: #27744
Testing with a Symfony skeleton
Assuming you have symfony installed and fetched this branch:
To make linter display an error
Add this services configuration to test the command:
It needs this fake service to be created:
Then run the command:
You should the error when passing an instance of logger to
MyService
constructor whereas it expects an instance ofstdClass
.