Skip to content
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

Default property with @NamedArgumentConstructor #402

Merged
merged 4 commits into from
Feb 14, 2021
Merged

Default property with @NamedArgumentConstructor #402

merged 4 commits into from
Feb 14, 2021

Conversation

Vincz
Copy link
Contributor

@Vincz Vincz commented Jan 19, 2021

Use the first constructor argument as default property when @NamedArgumentConstructor is used.

@@ -100,7 +100,8 @@ you can simplify this to:
}

Alternatively, you can annotate your annotation class with
``@NamedArgumentConstructor`` in case you cannot use the marker interface.
``@NamedArgumentConstructor`` in case you cannot use the marker interface.
In this case, the first argument of the constructor will be considered as the default property.
Copy link
Member

Choose a reason for hiding this comment

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

The way this is written makes me think that this works only when using the @NamedArgumentConstructor annotation but not when using the marker interface, as you added that in the paragraph about the alternative. Is it actually true ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is true. It is the intended behaviour as we didn't want to introduce breaking change with current implementation of the interface.
See #396

@Vincz
Copy link
Contributor Author

Vincz commented Jan 26, 2021

Hey guys :) Any feedback about this?
ping @greg0ire
ping @beberlei
ping @derrabus

@greg0ire
Copy link
Member

I see @derrabus mentioned that we should deprecate the interface, and I quite agree, I'd rather not have 2 ways of doing the same thing, with differences that are hard to grasp (the only comment on this PR so far is about exactly that BTW). Did you overlook that or is it planned for later?

@Vincz
Copy link
Contributor Author

Vincz commented Jan 29, 2021

@greg0ire Ok, the interface has been deprecated and the doc updated.

@greg0ire
Copy link
Member

Please kindly squash your commits together. Maybe not all, but some definitely needs to be squashed. If you don't, we'll try to remember to do it for you but it's best if you save us this trouble.

How to do that?

  1. git rebase -i origin/1.12.x, assuming origin is a git remote that points to this repository, and not your fork. If you're not sure what your remotes are, run git remote -vvv, there should be your fork and the holy/reference/base/origin/whatever-you-call-it repository.
  2. A window will show up with many lines, replace pick with fixup on every line but the first one
  3. Close your editor, git should do its magic, and you should end up with one commit
  4. Use git push --force to overwrite what you already push. Don't forget the --force option otherwise git will try to merge both things together.

Use the first constructor argument as default property when @NamedArgumentConstructor is used
@Vincz
Copy link
Contributor Author

Vincz commented Jan 31, 2021

@greg0ire Done!

greg0ire
greg0ire previously approved these changes Jan 31, 2021
@greg0ire
Copy link
Member

greg0ire commented Feb 5, 2021

ping @stof @beberlei 🙂

Copy link
Member

@beberlei beberlei left a comment

Choose a reason for hiding this comment

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

An idea to improve the patch a little to be less invasive.

@@ -859,7 +863,17 @@ private function Annotation()
);
}

$values = $this->MethodCall();
$defaultProperty = 'value';
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 this code block and passing down $defaultProperty could you instead handle this logic in the original line 944 and following?

 foreach ($values as $property => $value) {
    if ($property === 'value') {
        $property = self::$annotationMetadata[$name]['default_property'];
    }

That would be less invasive and keep the logic at fewer places. I am not 100% sure it could work this way though, just a rough idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @beberlei! Thx for the review. I didn't want to pass the defaultProperty down. The problem is, without it, we don't know with the return of values(), if value as been set because it's the default property... or because we have an argument named
value. So it would not work if for example the constructor looks like this:

public function __construct(string $name, int $value){}

Copy link
Member

Choose a reason for hiding this comment

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

But isnt that already a problem? Or just for the new named constructors?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I gave a bunch of examples here : #396.
The fact that we can't know if value has been set because it's the default property or because we have an argument with the same name, is, indeed an "old problem".
But we didn't want to change the current behaviour to avoid BC breaks, so we decide to handle it properly only with the new @NamedArgumentsConstructor annotation as it is not published yet.

Copy link
Member

Choose a reason for hiding this comment

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

The Problem is all methods with large letter starting are named after the rules in the grammer. None of them accept arguments, because they already operate on state of the class instead. Passing arguments now will create a huge inconsistency in the API.

What you could try instead is change the return value of Values to be an associative array with: ['named_arguments' => [], 'positional_arguments' => []] and then modify Annotation to act accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@beberlei Here is my first attempt trying what you suggested.
I was also wondering why this is allowed: @Name(@Name, @Name), but this is not @Name("1", "2"). The second case is not allowed because of this line:

throw $this->syntaxError('Value', $token);

In our case, it means that we can only use one positional argument. It would make sense for us to allow multiple positional arguments as it's also how attributes work. #[Name("1", "2")] would work for example as long as the related attribute constructor accept at least two arguments.
Removing the above line don't break the tests and allow multiple positional arguments.
What do you think about this?

Copy link
Member

Choose a reason for hiding this comment

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

In our case, it means that we can only use one positional argument. It would make sense for us to allow multiple positional arguments as it's also how attributes work.

Very well, but Doctrine Annotations only supports one positional argument: the default property. I would consider it out of scope of this PR to introduce the ability to have multiple ones.

Besides, a developer using an annotation does not know whether it has been implemented with a named constructor or not (and they should not need to know, imho). It would be kind of odd if multiple positional arguments only worked in the named argument constructor case.

Copy link
Member

Choose a reason for hiding this comment

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

Anyway, I'm a bit late to the party and I see that the discussion has already moved on. I'm fine with allowing multiple positional arguments. Thank you for working on this feature. 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@derrabus : Actually, Annotations already support multiple positional arguments. It's just that without @NamedArgumentConstructor all positional values will be populated as an array into value. This @Name(@Name, @Name) already work. So in a developer point of view, it doesn't really change anything.

Copy link
Member

@beberlei beberlei left a comment

Choose a reason for hiding this comment

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

Much better! Only some small things left from my pov, then good to merge

}
} else {
$values = $namedArguments;
if (! empty($positionalArguments) && ! isset($values['value'])) {
Copy link
Member

Choose a reason for hiding this comment

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

Please use count instead, empty does not communicate intent well

Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't count() > 0 much less effective than !empty() ?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, but the output of this code is usually cached and even if not, this is not a tight loop

Copy link
Member

Choose a reason for hiding this comment

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

I wanted to toy with phpbench so I build a little benchmark from this raw benchmark:

<?php

class ArrayCheckerBench
{
    public function provideArrays(): \Generator
    {
        yield 'empty array' => ['array' => []];
        yield 'huge array' => ['array' => range(0, 100000)];
    }

    public function checkArrayWithEmpty(array $x): bool
    {
        return empty($x);
    }

    public function checkArrayWithCount(array $x): bool
    {
        return count($x) === 0;
    }

    /**
     * @Revs(1000000)
     * @Iterations(5)
     * @ParamProviders({"provideArrays"})
     */
    public function benchCheckIfArrayIsEmptyWithEmpty(array $parameters): void
    {
        $this->checkArrayWithEmpty($parameters['array']);
    }

    /**
     * @Revs(1000000)
     * @Iterations(5)
     * @ParamProviders({"provideArrays"})
     */
    public function benchCheckIfArrayIsEmptyWithCount(array $parameters): void
    {
        $this->checkArrayWithCount($parameters['array']);
    }
}

Here are the results:

vendor/bin/phpbench run ArrayCheckerBench.php --report=aggregate --retry-threshold=0.5
PhpBench @git_tag@. Running benchmarks.

\ArrayCheckerBench

    benchCheckIfArrayIsEmptyWithEmpty # emp.I4 [μ Mo]/r: 0.285 0.285 (μs) [μSD μRSD]/r: 0.000μs 0.06%
    benchCheckIfArrayIsEmptyWithEmpty # hug.R1 I1 [μ Mo]/r: 0.287 0.287 (μs) [μSD μRSD]/r: 0.000μs 0.12%
    benchCheckIfArrayIsEmptyWithCount # emp.R1 I1 [μ Mo]/r: 0.309 0.309 (μs) [μSD μRSD]/r: 0.001μs 0.28%
    benchCheckIfArrayIsEmptyWithCount # hug.R3 I3 [μ Mo]/r: 0.311 0.311 (μs) [μSD μRSD]/r: 0.001μs 0.34%

2 subjects, 20 iterations, 4,000,000 revs, 0 rejects, 0 failures, 0 warnings
(best [mean mode] worst) = 0.285 [0.298 0.298] 0.285 (μs)
⅀T: 5.958μs μSD/r 0.001μs μRSD/r: 0.201%
suite: 134621f1b80d1bb3f7ed3eaf6bc32c7bb639d7d1, date: 2021-02-07, stime: 15:08:37
+-------------------+-----------------------------------+-------------+---------+-----+-------------+---------+---------+---------+---------+---------+--------+-------+
| benchmark         | subject                           | set         | revs    | its | mem_peak    | best    | mean    | mode    | worst   | stdev   | rstdev | diff  |
+-------------------+-----------------------------------+-------------+---------+-----+-------------+---------+---------+---------+---------+---------+--------+-------+
| ArrayCheckerBench | benchCheckIfArrayIsEmptyWithEmpty | empty array | 1000000 | 5   | 526,184b    | 0.285μs | 0.285μs | 0.285μs | 0.285μs | 0.000μs | 0.06%  | 1.00x |
| ArrayCheckerBench | benchCheckIfArrayIsEmptyWithEmpty | huge array  | 1000000 | 5   | 64,194,552b | 0.287μs | 0.287μs | 0.287μs | 0.288μs | 0.000μs | 0.12%  | 1.01x |
| ArrayCheckerBench | benchCheckIfArrayIsEmptyWithCount | empty array | 1000000 | 5   | 526,184b    | 0.308μs | 0.309μs | 0.309μs | 0.310μs | 0.001μs | 0.28%  | 1.08x |
| ArrayCheckerBench | benchCheckIfArrayIsEmptyWithCount | huge array  | 1000000 | 5   | 64,194,552b | 0.310μs | 0.311μs | 0.311μs | 0.313μs | 0.001μs | 0.34%  | 1.09x |
+-------------------+-----------------------------------+-------------+---------+-----+-------------+---------+---------+---------+---------+---------+--------+-------+

Looks like count takes almost 10% more time, so in relative terms, it's far better indeed. In absolute terms though, it seems to be 30 ns slower… I don't think we should worry about such small differences.

$positionalArguments = $arguments['positional_arguments'] ?? [];
$namedArguments = $arguments['named_arguments'] ?? [];

if (
Copy link
Member

Choose a reason for hiding this comment

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

Could you extract this block into a private helper please?

$values = $this->assembleValues($arguments, $name);

Method name could be better

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!

@Vincz
Copy link
Contributor Author

Vincz commented Feb 7, 2021

I was also wondering why this is allowed: @Name(@Name, @Name), but this is not @Name("1", "2"). The second case is not allowed because of this line:

throw $this->syntaxError('Value', $token);

In our case, it means that we can only use one positional argument. It would make sense for us to allow multiple positional arguments as it's also how attributes work. #[Name("1", "2")] would work for example as long as the related attribute constructor accept at least two arguments.
Removing the above line don't break the tests suite and allow multiple positional arguments.
What do you guys think about this?

@beberlei
Copy link
Member

beberlei commented Feb 7, 2021

@Vincz this only works for the new named argument ctor attributes though or? Because otherwise multiple values are handled as an array of value for the old Annotations or not? I would be fine supporting this for new annotations.

@Vincz
Copy link
Contributor Author

Vincz commented Feb 7, 2021

I think it would allow @Named("foo", "bar") without the @NamedArgumentConstructor but it would set value as an array like ["foo", "bar"]. Currently it works if provided values are objects or array but not with scalar values (it raise a Syntax error).
With @NamedArgumentConstructor it would use the constructor arguments, ie. "foo" for the first one and "bar" for the second one.

@beberlei
Copy link
Member

beberlei commented Feb 7, 2021

Ok

@Vincz
Copy link
Contributor Author

Vincz commented Feb 7, 2021

Ok, I'll add a bunch of tests then and make sure it doesn't break anything.

@Vincz
Copy link
Contributor Author

Vincz commented Feb 8, 2021

So, it is now possible to use @Name("foo", "bar") with regular annotation and we will get an array of string as explained.

With the @NamedArgumentConstructor we can now use multi positional values to initialise arguments.
I also added a check to ensure that it is not possible with @NamedArgumentConstructor to use @Annot("foo", bar=123, "baz") as it's an incorrect syntax with attributes / PHP 8 named arguments, and we want to mimic the behaviour.
I'm not sure about the raised exception type and message, let me know if you have a better idea.
Also, should I update the documentation to explain more deeply how @NamedArgumentConstructor work?

($lastPosition === null && $position !== 0 ) ||
($lastPosition !== null && $position !== $lastPosition + 1)
) {
throw $this->syntaxError('Positional arguments after named arguments is not allowed');
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
throw $this->syntaxError('Positional arguments after named arguments is not allowed');
throw $this->syntaxError('Positional arguments are not allowed after named arguments');

}

return $values;
return ['named_arguments' => $namedArguments, 'positional_arguments' => $positionalArguments];
Copy link
Member

Choose a reason for hiding this comment

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

In resolvePositionalValues, you validate $positionalArguments. Can't we do this here already? I don't like that we pass around potentially invalid metadata.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The Values() method is part of the parsing process. If we move the validation inside, it will have to know what kind of annotation we are talking about. The validation is different is you use @NamedArgumentConstructor or not.
This is valid without @Name("1", foo="bar", "2") but should not be valid with @NamedArgumentConstructor as it's supposed to mimic a regular constructor call with named arguments.

Copy link
Member

Choose a reason for hiding this comment

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

All right, thank you for clarifying. 👍🏻

Copy link
Member

@beberlei beberlei left a comment

Choose a reason for hiding this comment

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

Very good! I think we got a good state now that is ready to merge and then ship

@greg0ire greg0ire added this to the 1.12.0 milestone Feb 20, 2021
} else {
if (count($positionalArguments) > 0 && ! isset($values['value'])) {
if (count($positionalArguments) === 1) {
$value = $positionalArguments[0];

Choose a reason for hiding this comment

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

See #405

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.

7 participants