Skip to content

[DependencyInjection] Support local binding #22187

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

Closed
wants to merge 7 commits into from

Conversation

GuilhemN
Copy link
Contributor

@GuilhemN GuilhemN commented Mar 27, 2017

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets #22167, #23718
License MIT
Doc PR

A great idea came out on Slack about local bindings.
We could allow injecting services based on type hints on a per service/file basis:

services:
    _defaults:
        bind:
            BarInterface: '@usual_bar'

    Foo:
        bind:
            BarInterface: '@alternative_bar'
            $quz: 'quzvalue'

This way, @usual_bar will be injected in any parameter type hinted as BarInterface (in a constructor or a method signature), but only for this service/file.
Note that bindings could be unused, giving a better solution than #22152 to #21711.

As named parameters are usable in arguments, bindings could be usable in arguments too:

services:
    Foo:
        arguments:
            BarInterface: '@bar'

Named parameters aren't supported yet.

Edit:

Note that bindings could be unused

Current behavior is throwing an exception when a binding is not used at all, in no services of a file if it was inherited from _defaults or in no services created from a prototype.
It will pass if the bindings are all used in at least one service.

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

The target looks good to me, named args should be handled to make the feature complete to me.
Here are some friendly comments to help move forward.

*/
protected function processValue($value, $isRoot = false)
{
if (!$value instanceof Definition || empty($value->getBindings())) {
Copy link
Member

Choose a reason for hiding this comment

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

empty($value->getBindings()) > !$bindings = $value->getBindings()

$class = $parameterBag->resolveValue($class);
}

$bindings = $value->getBindings();
Copy link
Member

Choose a reason for hiding this comment

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

to be removed, see above

$parameterBag = $this->container->getParameterBag();

if ($class = $value->getClass()) {
$class = $parameterBag->resolveValue($class);
Copy link
Member

Choose a reason for hiding this comment

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

should be removed (also from ResolveNamedArgumentsPass): params have already been resolved at this stage.


foreach ($calls as $i => $call) {
list($method, $arguments) = $call;
$method = $parameterBag->resolveValue($method);
Copy link
Member

Choose a reason for hiding this comment

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

same comment (and same cleanup to do in ResolveNamedArgumentsPass)

ksort($arguments);
$calls[$i][1] = $arguments;
}
}
Copy link
Member

@nicolas-grekas nicolas-grekas Mar 28, 2017

Choose a reason for hiding this comment

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

$bindings should be allowed to contain $named args, so that it can be used with scalar/array params also.
$bindings could also be validated: what about throwing when a key doesn't match any existing class/interface? That would help people spot typos easily.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

$bindings should be allowed to contain $named args, so that it can be used with scalar/array params also.

Yes I have to do that :)

$bindings could also be validated: what about throwing when a key doesn't match any existing class/interface? That would help people spot typos easily.

What if the class/interface doesn't exist/is not loaded yet? Is this an unsupported case?

Copy link
Member

@nicolas-grekas nicolas-grekas Mar 28, 2017

Choose a reason for hiding this comment

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

What if the class/interface doesn't exist

unsupported yes, DX is way more important than supporting funky edge cases


foreach ($defaults['bindings'] as $binding) {
if (!$binding->hasAttribute('id')) {
throw new InvalidArgumentException(sprintf('The binding service id for tag "<defaults>" in %s must be defined.', $file));
Copy link
Member

Choose a reason for hiding this comment

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

should allow $named args

@@ -333,6 +341,22 @@ private function parseDefinition(\DOMElement $service, $file, array $defaults =
$definition->addAutowiringType($type->textContent);
}

$bindingNodes = $this->getChildren($service, 'binding');
if (empty($bindingNodes) && !empty($defaults['bindings'])) {
Copy link
Member

Choose a reason for hiding this comment

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

~empty()~~

$bindings = array();
foreach ($bindingNodes as $binding) {
if (!$binding->hasAttribute('id')) {
throw new InvalidArgumentException(sprintf('The "id" attribute for binding type "%s" for service "%s" in %s must be defined.', $type, (string) $service->getAttribute('id'), $file));
Copy link
Member

Choose a reason for hiding this comment

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

$named args

Copy link
Member

Choose a reason for hiding this comment

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

which means we need a type="service/collection" attribute on xml

throw new InvalidArgumentException(sprintf('The "id" attribute for binding type "%s" for service "%s" in %s must be defined.', $type, (string) $service->getAttribute('id'), $file));
}

$bindings[$binding->getAttribute('type')] = new Reference($binding->getAttribute('id'));
Copy link
Member

Choose a reason for hiding this comment

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

type should renamed to eg "key" (to allow "type" to be used as suggested previously)


$definition->setBindings($bindings);
} elseif (isset($defaults['bindings'])) {
$definition->setBindings($defaults['bindings']);
Copy link
Member

Choose a reason for hiding this comment

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

I'd expect default bindings to be merged with service ones, don't you?

@nicolas-grekas nicolas-grekas added this to the 3.3 milestone Mar 28, 2017
@GuilhemN GuilhemN force-pushed the BINDING branch 2 times, most recently from ec233a9 to a4706d9 Compare March 28, 2017 17:26
@GuilhemN
Copy link
Contributor Author

GuilhemN commented Mar 28, 2017

@nicolas-grekas thanks for you comments, they should all be fixed now 😃

}

foreach ($bindings as $key => $binding) {
if ($key && '$' === $key[0]) {
Copy link
Member

Choose a reason for hiding this comment

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

$key can be a number at this stage (as bogus as it would be) - then $key[0] would break
!isset($key[0]) || '$' === $key[0] instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A number should not be supported so I'd say isset($key[0]) && '$' === $key[0], thanks!

continue;
}

if (null !== $binding && !$binding instanceof Reference) {
Copy link
Member

Choose a reason for hiding this comment

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

what about allowing Definition also?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

throw new InvalidArgumentException(sprintf('Invalid value for binding type "%s" for service "%s": expected null, an instance of %s or an instance of %s, %s given.', $key, (string) $this->currentId, Reference::class, Definition::class, gettype($binding)));
}

if (!class_exists($key) && !interface_exists($key)) {
Copy link
Member

Choose a reason for hiding this comment

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

missing false as 2nd arg of interface_exists

Copy link
Member

@nicolas-grekas nicolas-grekas Mar 28, 2017

Choose a reason for hiding this comment

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

but thinking more about this, you told me you would prefer throwing when a binding is never used.
we can do that: we just need to move the deep clone that is done in FileLoader to ResolveDefinitionInheritancePass!
I'd prefer this over this class_exists check myself (because it makes things context-sensitive)

}

if (null !== $binding && !$binding instanceof Reference && !$binding instanceof Definition) {
throw new InvalidArgumentException(sprintf('Invalid value for binding type "%s" for service "%s": expected null, an instance of %s or an instance of %s, %s given.', $key, (string) $this->currentId, Reference::class, Definition::class, gettype($binding)));
Copy link
Member

Choose a reason for hiding this comment

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

for binding "key" (reusing vocabulary from XmlLoader)

}

if (!class_exists($key) && !interface_exists($key)) {
throw new InvalidArgumentException(sprintf('Invalid binding type "%s" for service "%s": this class/interface does not exist.', $key, $this->currentId));
Copy link
Member

Choose a reason for hiding this comment

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

s/type/key
this class or interface

}

foreach ($reflectionMethod->getParameters() as $key => $parameter) {
if (isset($arguments[$key])) {
Copy link
Member

Choose a reason for hiding this comment

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

array_key_exists

Copy link
Member

Choose a reason for hiding this comment

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

in fact, AutowirePass has a trick here, which we should account for: null is not autowired, but an empty string with a non-internal type-hint triggers autowiring - we should do the binding here also in this case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

updated

@@ -50,21 +44,39 @@ protected function processValue($value, $isRoot = false)
$resolvedArguments[] = $argument;
continue;
}
if ('' === $key || '$' !== $key[0]) {

if ('' === $key || ('$' !== $key[0] && !class_exists($key) && !interface_exists($key))) {
Copy link
Member

Choose a reason for hiding this comment

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

here we don't need the class_exists check: we should just throw when a binding is not used (as done for $named args already), this will make things context-free again

$resolvedArguments[$j] = $argument;

continue 2;
}
}

throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": method "%s::%s" has no argument named "%s". Check your service definition.', $this->currentId, $class, $method, $key));
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": method "%s::%s" has no argument type hinted as "%s". Check your service definition.', $this->currentId, $value->getClass(), $method, $key));
Copy link
Member

Choose a reason for hiding this comment

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

type-hinted (with a dash)

}

$calls = $value->getMethodCalls();
$calls[] = array(null, $value->getArguments()); // constructor
Copy link
Member

Choose a reason for hiding this comment

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

instead of null: $class->hasConstructor() ? $class->getConstructor()->name : '__construct'
which implies moving the getReflectionClass call outside of the getReflectionMethod call, which is nice because it is currently in a loop for no reason.
Same on ResolveNamedArgsPass of course.

@lunetics
Copy link

+1, how much is left to do? can't await it!

@GuilhemN
Copy link
Contributor Author

For me it's ready.
Maybe we can do something about #22187 (comment) but I don't see how to improve this part of the code for now.

Copy link
Contributor

@ro0NL ro0NL left a comment

Choose a reason for hiding this comment

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

services:
    Foo:
        arguments:
            BarInterface: '@bar'

Not sure if BarInterface has a special meaning here? given only integer or $named arguments are allowed.

edit: ah i see the change in ResolveNamedArguments now 👍

continue;
}

if (null !== $binding && !$binding instanceof Reference && !$binding instanceof Definition) {
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldnt this check go first?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, any value is allowed for named arguments.
Though i have to add it in the other pass.

Copy link
Contributor

Choose a reason for hiding this comment

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

that is true 👍

$calls = $value->getMethodCalls();

$constructor = $reflectionClass->getConstructor();
$calls[] = array(null !== $constructor ? $constructor->name : '__construct', $value->getArguments()); // constructor
Copy link
Contributor

Choose a reason for hiding this comment

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

comment not needed imo. this is obvious.

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

here are some comments for another iteration.
can you look at descriptors also please?

}

if (!class_exists($key) && !interface_exists($key, false)) {
throw new InvalidArgumentException(sprintf('Invalid binding key "%s" for service "%s": this class/interface does not exist.', $key, $this->currentId));
Copy link
Member

Choose a reason for hiding this comment

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

class or interface (instead of the slash)


$class = $value->getClass();
if (!$class) {
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": the class is not set.', $this->currentId));
Copy link
Member

Choose a reason for hiding this comment

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

no need for this block, autowire pass does:
if (!$reflectionClass = $this->container->getReflectionClass($definition->getClass(), true)) {
which is enough

throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": the class is not set.', $this->currentId));
}

if (!$reflectionClass = $this->container->getReflectionClass($class)) {
Copy link
Member

Choose a reason for hiding this comment

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

see previous comment

}

if (array_key_exists('$'.$parameter->name, $bindings)) {
$arguments[$key] = $bindings['$'.$parameter->name];
Copy link
Member

Choose a reason for hiding this comment

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

to help spot typos in named args, what about keeping a pass-wide property of all named args and their usages, and throw for unused ones at the end of the pass?


if (null === $parameters) {
if (!$class) {
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": the class is not set.', $this->currentId));
Copy link
Member

Choose a reason for hiding this comment

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

not needed, already checked just below

@GuilhemN GuilhemN force-pushed the BINDING branch 3 times, most recently from 3101fb3 to 94d6da9 Compare April 1, 2017 20:05
@GuilhemN
Copy link
Contributor Author

GuilhemN commented Apr 1, 2017

can you look at descriptors also please?

As for named arguments the updated arguments are shown by the descriptors, so I agree with you, I don't think we need to support the bindings in the descriptors.

to help spot typos in named args, what about keeping a pass-wide property of all named args and their usages, and throw for unused ones at the end of the pass?

I found a way to throw an error when a binding is used nowhere so no need to do this anymore.

fabpot added a commit that referenced this pull request Apr 4, 2017
…g (nicolas-grekas)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[DI] Add "factory" support to named args and autowiring

| Q             | A
| ------------- | ---
| Branch?       | 3.3
| Bug fix?      | yes
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

To me, factories are expected to work named arguments.
Doing so also unlocks supporting them when autowiring, and will benefit #22187 soon.

Commits
-------

27470de [DI] Add "factory" support to named args and autowiring
}
}

if ($value->getFactory() || null !== $constructor) {
Copy link
Member

Choose a reason for hiding this comment

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

missing logic update?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes 👍


if (null === $parameters) {
$r = $this->getReflectionMethod($value, $method);
$class = $r instanceof \ReflectionMethod ? $r->class : $this->currentId;
$parameters = $r->getParameters();
}

if ('$' === $key[0]) {
Copy link
Member

Choose a reason for hiding this comment

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

breaks when $key === ''?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, added a isset check

@GuilhemN
Copy link
Contributor Author

The build failure is because this is not yet in master.

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Jul 31, 2017

this PR description could be enhanced to emphasis the use of mapping arguments to parameters, because that's a common need, see #23718

@javiereguiluz
Copy link
Member

javiereguiluz commented Jul 31, 2017

Can we improve the DX of this feature? If this comment by @GuilhemN is correct, this is how the feature works now:

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

        bind:
            $argument1: '%kernel.project_dir%'
            $argument2: '%app.container_param%'
            $argument3: '%app.another_param%'

    # ...

This is my proposal:

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false
        arguments:
            $argument1: '%kernel.project_dir%'
            $argument2: '%app.container_param%'
            $argument3: '%app.another_param%'

    # ...

My reasoning:

  • This feature is about defining the default value of scalar arguments, so "_defaults.arguments" sounds natural.
  • We wouldn't introduce yet another config option ("bind").

@GuilhemN
Copy link
Contributor Author

GuilhemN commented Jul 31, 2017

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    bind:
        $argument1: '%kernel.project_dir%'
        $argument2: '%app.container_param%'
        $argument3: '%app.another_param%'

    # ...

bind is used under _defaults or in a service definition, it's not a special option.

@javiereguiluz arguments are not the same, they can only be used in the constructor while bindings are meant to be used in the constructor, method calls and in action arguments.
Bindings are also optional (no need to be used in all services) while arguments must be used in every service of the file/resource.

This feature is about defining the default value of scalar arguments, so "_defaults.arguments" sounds natural.

That's just one use case :) It can also be used in libraries as a totally predictable autowiring, to deal with multiple instance of the same class, to have a nicer syntax to replace services in action arguments and so on.
And as I said earlier arguments are far more limited, having a different behavior in _defaults for arguments would be weird.

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

Small remaining comments.
This should be merged now, we already identified several cases where this feature is useful and would be a nice addition.
If we don't merge, we cannot improve it later if needed - nor experience it for real.

*/
protected function processValue($value, $isRoot = false)
{
if ($value instanceof TypedReference) {
Copy link
Member

@nicolas-grekas nicolas-grekas Aug 7, 2017

Choose a reason for hiding this comment

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

&& $value->getType() === (string) $value because typed reference can be configured with an explicit id (as done in autowiring pass L330 in getAutowiredReference)
needs a test case

return parent::processValue($value, $isRoot);
}

foreach ($value->getBindings() as $key => $binding) {
Copy link
Member

Choose a reason for hiding this comment

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

foreach ($bindings as ...

$bindings = $this->getArgumentsAsPhp($service, 'bind', $file);
if (isset($defaults['bind'])) {
// deep clone, to avoid multiple process of the same instance in the
// passes
Copy link
Member

Choose a reason for hiding this comment

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

comment should be on one line
// deep clone, to avoid processing the same definitions several times in compiler passes

@@ -519,6 +534,22 @@ private function parseDefinition($id, $service, $file, array $defaults)
}
}

if (isset($defaults['bind']) || isset($service['bind'])) {
// deep clone, to avoid multiple process of the same instance in the
Copy link
Member

Choose a reason for hiding this comment

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

see above

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Aug 8, 2017

@javiereguiluz here is a typical example:

services:
  _defaults:
    bind:
      $projectDir: '%kernel.project_dir%'

This means that any arguments (constructor or setter) that are named $projectDir for services defined in the current file will get the kernel.project_dir value, provided the services don't themselves explicitly set any other values of course.

We could even put that specific line in the default flex config. (no, because that would trigger an "unused binding" kind of exception, we preferred to ease spotting typos here)

Do we get your vote? :)

@GuilhemN
Copy link
Contributor Author

GuilhemN commented Aug 8, 2017

@nicolas-grekas comments fixed :)

@nicolas-grekas
Copy link
Member

Thank you @GuilhemN.

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

Successfully merging this pull request may close these issues.

9 participants