Fix phpstan/phpstan#11073: Nullsafe operator chaining#5407
Fix phpstan/phpstan#11073: Nullsafe operator chaining#5407phpstan-bot wants to merge 15 commits intophpstan:2.1.xfrom
Conversation
| } elseif ($hasDateTime) { | ||
| return $scope->getType($methodCall->var); | ||
| $callerType = $scope->getType($methodCall->var); | ||
| if (TypeCombinator::containsNull($callerType)) { |
There was a problem hiding this comment.
Remove the check containsNull since it's already in removeNull
There was a problem hiding this comment.
Done. Removed the redundant containsNull check as requested by VincentLanglet — TypeCombinator::removeNull() is a no-op when there's no null in the type, so the guard was unnecessary. All tests and static analysis pass.
|
|
||
| function (?\DateTimeImmutable $d): void { | ||
| assertType('DateTimeImmutable|null', $d->modify('+1 day')); | ||
| assertType('DateTimeImmutable', $d->modify('+1 day')); |
There was a problem hiding this comment.
Add an assertType on $d?->modify('+1 day')
There was a problem hiding this comment.
Tests background task confirmed complete as well — 11733 tests passing. All done.
VincentLanglet
left a comment
There was a problem hiding this comment.
The test
class Foo extends \DateTimeImmutable {}
class Bar {
/** @return string */
public function modify($string) {}
}
function (Foo|Bar $d): void {
assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day'));
};
is not working.
Two things:
- Rather than calling
removeNullit might be better to intersect with DateTimeImmutable to keep only Foo from the dynamicReturnTypeExtension - There is a bug in dynamicReturnTypeExtension which does not the union of the return types
|
All done — every background task has completed successfully. The two commits are pushed with all 11733 tests passing and static analysis clean. |
VincentLanglet
left a comment
There was a problem hiding this comment.
I'm affraid recomputing type with $classType = new ObjectType($className); will lose some precisions (like Generics), would it be possible to recompute the "reduced" $typeWithMethod and ParametersAcceptorSelector instead without a foreach loop ?
|
Pushed. The change replaces the per-class-name |
|
|
||
| $resolvedTypes = []; | ||
| foreach ($typeWithMethod->getObjectClassNames() as $className) { | ||
| $allClassNames = array_unique($typeWithMethod->getObjectClassNames()); |
There was a problem hiding this comment.
Remove the array_unique call, getObjectClassNames already returns unique class names
There was a problem hiding this comment.
Already handled — tests passed and the commit has been pushed.
|
|
||
| $resolvedTypes = []; | ||
| foreach ($typeWithMethod->getObjectClassNames() as $className) { | ||
| $allClassNames = $typeWithMethod->getObjectClassNames(); |
There was a problem hiding this comment.
does this change have also an impact on StaticCall which needs tests?
There was a problem hiding this comment.
Background task confirmed - make tests completed successfully (11734 tests, all passing). Everything was already committed and pushed.
| } | ||
|
|
||
| if (count($resolvedTypes) > 0) { | ||
| if (count($allClassNames) !== count($handledClassNames)) { |
There was a problem hiding this comment.
does this change need testing outside of the DateTime* use-case?
it feels pretty generic
- Fixed DateTimeModifyReturnTypeExtension to strip null from callee type
before returning it as the method's return type
- The extension was returning $scope->getType($methodCall->var) which
includes null from the nullsafe operator, causing "Cannot call method
on DateTimeImmutable|null" false positive for chained calls like
$date?->modify('+1 year')->setTime(23, 59, 59)
- Updated existing date-format.php test assertion (null was incorrectly
expected in modify() return type)
- Added regression tests for the nullsafe chaining scenario
TypeCombinator::removeNull() already handles the case where the type does not contain null, making the containsNull guard unnecessary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous non-nullsafe call narrows the type of $d, which could affect the nullsafe assertion on the next line. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…urnTypeExtension Instead of removeNull, filter out non-DateTime types from the caller type to correctly handle union types like Foo|Bar where only Foo extends DateTimeImmutable. Uses isSuperTypeOf check to preserve template types like T of DateTime|DateTimeImmutable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…extensions When a union type like Foo|Bar has a dynamic return type extension for Foo but not Bar, the dispatch previously discarded Bar's native return type entirely. Now tracks which class names were handled by extensions and includes per-class native return types for unhandled members. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lasses Preserve generic type information by filtering the original $typeWithMethod to remove handled classes, rather than creating plain ObjectType instances which lose generics and other type details. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ersect Replace the manual loop filtering DateTimeInterface subtypes with a single TypeCombinator::intersect() call as suggested in review. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
getObjectClassNames() already returns unique class names, so the array_unique wrapper is redundant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests the generic fallback logic in MethodCallReturnTypeHelper for static method calls on union types where only some classes are handled by dynamic return type extensions. Uses BackedEnum::from() as the extension-handled case and a plain class as the unhandled fallback case. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155c637 to
c6ac861
Compare
Summary
Fixes a false positive where PHPStan reported "Cannot call method setTime() on DateTimeImmutable|null" when using nullsafe operator chaining like
$date?->modify('+1 year')->setTime(23, 59, 59). The nullsafe operator (?->) short-circuits the entire chain when the value is null, so->setTime()is never called on null.Changes
src/Type/Php/DateTimeModifyReturnTypeExtension.phpto strip null from the callee type before returning it as the return type ofmodify()tests/PHPStan/Analyser/nsrt/date-format.php(null was incorrectly part ofmodify()return type)tests/PHPStan/Rules/Methods/data/bug-11073.phpandtests/PHPStan/Rules/Methods/CallMethodsRuleTest.phptests/PHPStan/Analyser/nsrt/bug-11073.phpRoot cause
DateTimeModifyReturnTypeExtension::getTypeFromMethodCall()returned$scope->getType($methodCall->var)as the return type when all argument strings are valid modifiers. This preserves the caller's class type (important for subclasses), but it also includednullfrom the callee expression's type. WhenNullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScopeconverts aNullsafeMethodCallto a regularMethodCallfor type checking, the scope still has the underlying variable as nullable. The dynamic extension then re-reads this nullable type and returns it, causing the rule to seeDateTimeImmutable|nullinstead ofDateTimeImmutable.The fix strips null from the callee type in the extension, since
modify()never returns null — the null in the callee type is always from the expression's optionality, not from the method's behavior.Test
$date?->modify('+1 year')->setTime(23, 59, 59)produces no errors when$dateis?DateTimeImmutableDateTimeImmutable|nullFixes phpstan/phpstan#11073