Skip to content

Add extensible ClassReflection::getAllowedSubTypes() #1477

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

Merged
merged 12 commits into from
Oct 16, 2022

Conversation

jiripudil
Copy link
Contributor

...to allow restricting inheritance hierarchies in userland – but also internally; I've rewritten the current DateTimeInterface and enums support into this extension point.

Closes phpstan/phpstan#7493

Copy link
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

This is so nice! I'm blown away 🤯 I have a few minor points to make but I'll wait until the build is green :)

@ondrejmirtes
Copy link
Member

I guess with this extension in place, we should be able to write a rule in phpstan-src that actually checks an extending class in regard to ClassReflection::getAllowedSubTypes()?

@ondrejmirtes
Copy link
Member

And yeah, sorry, the build isn't going to be absolutely green because of some failures I have in https://github.com/phpstan/phpstan/actions/workflows/extension-tests-run.yml now.

@jiripudil
Copy link
Contributor Author

It seems most of the failures relate to the null-safe operator I've used in ObjectType. Am I not allowed to use it, even though composer.json requires PHP ^8.0?

@ondrejmirtes
Copy link
Member

@jiripudil I think it's a question of configuring Rector to downgrade it correctly here https://github.com/phpstan/phpstan-src/blob/1.7.x/build/rector-downgrade.php. (DowngradeNullsafeToTernaryOperatorRector is missing there).

@jiripudil
Copy link
Contributor Author

Also, I've realized this probably won't play well with generics, be it generic parent, generic descendant, or both. I don't have a use case for that right now, but I can imagine someone might have. I'll give it some thought to see if it's possible to implement it later with the current API.

@jiripudil jiripudil force-pushed the class-allowed-subtypes branch from 6d7b2af to 6209aa9 Compare June 27, 2022 07:43
continue;
}

$subTypes[] = $allowedSubTypesClassReflectionExtension->getAllowedSubTypes($this);
Copy link
Contributor

Choose a reason for hiding this comment

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

do we know a use-case where we would have several extensions for the same type?

I am wondering whether this loop should jus return whatever the first subtype class extensions' getAllowedSubTypes returns (which supports the current type)?

@jiripudil jiripudil force-pushed the class-allowed-subtypes branch from 6209aa9 to 7ef140b Compare September 16, 2022 19:57
@jiripudil jiripudil changed the base branch from 1.7.x to 1.8.x September 16, 2022 19:58
@jiripudil jiripudil force-pushed the class-allowed-subtypes branch from 7ef140b to 09012ff Compare September 22, 2022 07:39
@ondrejmirtes
Copy link
Member

I'd really welcome the rule that checks allowed extends/implements here :) Otherwise it's 👍

@jiripudil jiripudil force-pushed the class-allowed-subtypes branch 2 times, most recently from b657342 to 486d84e Compare September 22, 2022 16:32
public function testRule(): void
{
require __DIR__ . '/data/allowed-sub-types.php';
$this->analyse([__DIR__ . '/data/allowed-sub-types.php'], [
Copy link
Member

Choose a reason for hiding this comment

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

Can you also test that:

  1. DateTime CAN be extended by userland classes
  2. DateTimeInterface CANNOT be implemented by userland classes

https://3v4l.org/f7V26

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, while we're at it, I've also added an extension (and test) for Throwable

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm. Turns out you can add an explicit implements DateTimeInterface to a class as long as it is also a descendant of DateTime or DateTimeImmutable (https://3v4l.org/nOU76). The same goes for Throwable.

I could account for that in the rule, but I'm afraid it might not work that well for other use cases – including my original use case of sealed classes.

I'm starting to think this is trying to accomplish two different things, and perhaps we should only focus on one here – provide better type narrowing for restricted hierarchies. I guess we could come up with better names to make it clear that the extension only focuses on this and that it alone doesn't give any guarantees that the inheritance hierarchy is actually restricted, leaving it up to the applications (and phpstan-extensions) to enforce that via a custom rule based on their specific needs and conventions.

Having said that, I think that PHP natives like DateTimeInterface and Throwable should be covered by PHPStan, but that a more targeted rule which takes the aforementioned quirks into account would be a better fit in this case.

WDYT?

Copy link
Member

Choose a reason for hiding this comment

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

It's complicated. This new rule has to somehow account for repeated implements DateTimeInterface because PHPStan will already include an extension that describes this so if this rule wouldn't allow it it would lead to false positives. (AllowedSubTypesRule should allow this https://3v4l.org/nOU76 without any errors reported.).

I think we can achieve that - if we find a piece of code that violates the constraints set up by extensions but the piece of code duplicates a fact that's already true from an inherited parent, this rule shouldn't report this?

So - DateTime already implements DateTimeInterface, class Foo extends DateTime, therefore class Bar extends Foo implements DateTimeInterface shouldn't be reported because DateTime and Foo already implement DateTimeInterface.

Is this doable? I think it will get use all the benefits we want still :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, there are other edge cases, such as https://3v4l.org/nbLp6 – having a userland interface extend DateTimeInterface or Throwable is perfectly valid, but you can only implement that interface if the implementation extends a valid implementation such as DateTime or Exception. So the rule would have to account for this possible indirection too and make distinctions between parent class hierarchy, directly implemented interfaces, indirectly implemented interfaces, etc. It sounds quite complex, but it should be doable :)

The question is, are you ok with this rule (or rules) specifically targeting DateTimeInterface and Throwable, and there being no built-in general rule covering custom AllowedSubTypes extensions? That way, all restricted hierarchies that are supported out-of-the-box would be covered by PHPStan (hence no false positives), and userland AllowedSubTypes extensions would be allowed (and expected) to enforce their conventions.

For example, I would like sealed classes/interfaces to be strict:

#[Sealed(permits: [ImplementationOne::class, ImplementationTwo::class])]
interface SealedInterface {}

// I want this to fail, SubSealedInterface is not in "permits":
interface SubSealedInterface
    extends SealedInterface {}

// and this too (it's an error in Kotlin, and I'd like it to be consistent)
class SubImplementation
    extends ImplementationOne
    implements SealedInterface {}

Writing a custom SealedClassRule will allow me to not only tailor the behaviour to my expectations but also provide a more helpful error message.

Copy link
Member

Choose a reason for hiding this comment

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

I think we have two possible paths to take here:

  1. Include the new rule in this PR but continue hardcoding sealed DateTimeInterface class hierarchy as it is now on 1.8.x. So DateTimeInterfaceAllowedSubTypesClassReflectionExtension and ThrowableAllowedSubTypesClassReflectionExtension wouldn't be part of this PR. This means that the new rule would only target custom allowedSubTypes extensions.
  2. Leave things as they are now in this PR but remove AllowedSubTypesRule.

I think 1) makes more sense because people aren't required to implement their custom (very repetetive) rules, they just have to provide custom extension to define the class hierarchy. The downside is that PHPStan internals will be a bit more ugly but that's fine.

WDYT? After this we can implement a completely separate rule to detect situations when the user shouldn't implement DateTimeInterface, Throwable etc...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, that makes sense to me :)

/**
* @implements Rule<InClassNode>
*/
class AllowedSubTypesRule implements Rule
Copy link
Member

Choose a reason for hiding this comment

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

This rule isn't registered. Level 0 would be fine 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, added, thanks.

@jiripudil jiripudil force-pushed the class-allowed-subtypes branch 2 times, most recently from e6f9f0b to c3625ba Compare September 26, 2022 09:24
@jiripudil jiripudil force-pushed the class-allowed-subtypes branch from c3625ba to 565a534 Compare October 14, 2022 06:06
@jiripudil jiripudil changed the base branch from 1.8.x to 1.9.x October 14, 2022 06:08
@ondrejmirtes
Copy link
Member

Perfect! I especially like the code generalization in ObjectType and StaticType about enum subtraction :) Thank you very much.

@ondrejmirtes ondrejmirtes merged commit 0cefb9b into phpstan:1.9.x Oct 16, 2022
@jiripudil jiripudil deleted the class-allowed-subtypes branch October 17, 2022 08:17
@ondrejmirtes
Copy link
Member

FYI I've just written a documentation about this feature: https://phpstan.org/developing-extensions/allowed-subtypes Feel free to add anything!

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

Successfully merging this pull request may close these issues.

Representing custom restricted hierarchies
3 participants