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

Fix !isset() with ArrayDimFetch #2657

Closed
wants to merge 40 commits into from
Closed

Fix !isset() with ArrayDimFetch #2657

wants to merge 40 commits into from

Conversation

staabm
Copy link
Contributor

@staabm staabm commented Sep 30, 2023

@staabm
Copy link
Contributor Author

staabm commented Sep 30, 2023

I can see in the issue bot, that the change as is would resolve a lot of open issues.

I have no idea yet, how to express the "nullability" in the !isset() case.

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.

So if we're going to handle isset in falsy context, we need to understand what isset does. With !isset($foo) it means:

  1. The variable might not exist
  2. The variable might be null

We need to test these scenarios:

  1. Variable with certainty maybe and nullable
  2. Variable with certainty maybe and not nullable
  3. Variable with certainty yes and nullable
  4. Variable with certainty yes and not nullable

For !isset($foo['bar']), it means:

  1. The key does not exist
  2. The key exists but it's null

So various scenarious with array{foo: int} and array{foo: int|null} and also array{foo?: int} and array{foo?: int|null} need to be tested.

Plus for !isset($foo['bar']) it might also mean that anything in the chain might also not exist so the first 4) points about !isset($foo) apply to !isset($foo['bar']).

I think the main hurdle here is that SpecifiedTypes currently cannot express a command to unset a variable. Because for $foo with certainty maybe and not-nullable, the only !isset($foo) is that the variable does not exist, so the certainty needs to be no.

But we can introduce some kind of UnsetExpr virtual node that would be in SpecifiedTypes and would instruct MutatingScope::filterBySpecifiedTypes to unset the variable from scope. That might or might not be the way to go.

src/Analyser/ExpressionTypeHolder.php Outdated Show resolved Hide resolved
src/Analyser/MutatingScope.php Outdated Show resolved Hide resolved
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.

Also please write regression tests for all the issues the issue bot found (after the first original iteration), to make sure we don't break them during the review.

$specifiedExpressions = [];
foreach ($typeSpecifications as $typeSpecification) {
$expr = $typeSpecification['expr'];
$type = $typeSpecification['type'];

if ($expr instanceof NotIssetExpr) {
$unsetExpr = $expr->getExpr();
Copy link
Member

Choose a reason for hiding this comment

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

Do this only for 'sure'=true.

Copy link
Contributor Author

@staabm staabm Oct 4, 2023

Choose a reason for hiding this comment

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

moving it into the 'sure'=true branch regresses some tests. I think what you want to achieve is already taken care of in the TypeSpecifier side of this PR, as NotIssetExpr is only passed thru in the !$context->true()-case

@staabm staabm marked this pull request as ready for review October 4, 2023 16:17
@phpstan-bot
Copy link
Collaborator

This pull request has been marked as ready for review.

@staabm
Copy link
Contributor Author

staabm commented Oct 4, 2023

I am bit a lost atm .. will take a break and look again at it later.

src/Analyser/TypeSpecifier.php Outdated Show resolved Hide resolved
@staabm
Copy link
Contributor Author

staabm commented Oct 4, 2023

the only remaining errors are 2 test expectations, which I am not sure wheter my expectations are wrong, or there is a open todo:

1) PHPStan\Analyser\NodeScopeResolverTest::testFileAsserts with data set "/home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Analyser/data/falsy-isset.php:84" ('variableCertainty', '/home/runner/work/phpstan-src...et.php', PHPStan\TrinaryLogic Object (...), PHPStan\TrinaryLogic Object (...), 'a', 84)
Expected Maybe, actual certainty of variable $a is Yes in /home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Analyser/data/falsy-isset.php on line 84.
Failed asserting that false is true.

/home/runner/work/phpstan-src/phpstan-src/src/Testing/TypeInferenceTestCase.php:110
/home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Analyser/NodeScopeResolverTest.php:1369

2) PHPStan\Analyser\NodeScopeResolverTest::testFileAsserts with data set "/home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Analyser/data/falsy-isset.php:88" ('variableCertainty', '/home/runner/work/phpstan-src...et.php', PHPStan\TrinaryLogic Object (...), PHPStan\TrinaryLogic Object (...), 'a', 88)
Expected Maybe, actual certainty of variable $a is Yes in /home/runner/work/phpstan-src/phpstan-src/tests/PHPStan/Analyser/data/falsy-isset.php on line 88.
Failed asserting that false is true.

Comment on lines 735 to 791
$newType = TypeTraverser::map($newType, static function (Type $type, callable $traverse) use ($dimType): Type {
if ($type instanceof UnionType || $type instanceof IntersectionType) {
return $traverse($type);
}

if ($type instanceof ConstantArrayType) {
$builder = ConstantArrayTypeBuilder::createFromConstantArray(
$type,
);
$builder->setOffsetValueType(
$dimType,
new NullType(),
true,
);
return $builder->getArray();
}

return $type->setOffsetValueType($dimType, new NullType());
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

instead of this TypeTraverser block, we could add a optional new arg to Type->setOffsetValueType()

$var->var,
$newType,
$context->negate(),
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.

I think there is no way arround override=true here, because we do not intersect/union the previous known array-shape, but just update a single offset of it.

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.

Please solve not only ArrayDimFetch but also standalone variables that I talk about here #2657 (review)

$exprString = $this->getNodeKey($unsetExpr->var);

unset($scope->expressionTypes[$exprString]);
unset($scope->nativeExpressionTypes[$exprString]);
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand this logic at all. Here you're saying that if we unset($var['foo']) and the $var is an empty array, you're going to unset $var !?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is the implementation part of the tests which fail atm

the idea is to implement what was described in

Because for $foo with certainty maybe and not-nullable, the only !isset($foo) is that the variable does not exist, so the certainty needs to be no.

let me fix it

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 am doing this array stuff here, because I think:

a array shape which only has a single offset, which gets checked with isset() on said offset will never have the variable defined in the falsey-branch

$a = ['foo' => 'abc];
if (isset($a['foo'])) {
} else {
  // $a is always undefined 
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

since we don't want to go from YES to NO certainty, I think my above case is not possible and I should not try to support it?

Copy link
Member

Choose a reason for hiding this comment

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

$a = ['foo' => 'abc'];
if (isset($a['foo'])) {
} else {
  // $a should be NeverType here
}

@staabm
Copy link
Contributor Author

staabm commented Oct 5, 2023

just pushed the implementation for variables and a bunch of tests.

main problem is still the certainty stuff, and I think most - if not all - remaining test-failures feel related to certainty


and mooooore regression tests

@staabm
Copy link
Contributor Author

staabm commented Oct 6, 2023

I have an idea how to fix the remaining problems, but just need a bit more time.
I will ping you when things are ready for another review

@staabm staabm marked this pull request as draft October 6, 2023 20:34
@staabm
Copy link
Contributor Author

staabm commented Oct 8, 2023

@ondrejmirtes I did a lot of debugging locally and have some changes planned, but I think the main problem I am facing atm is, that when the TypeSpecifier is invoked for the Isset_ expression, the falsey-scope no longer knows the maybe certainty of $a.

the scope in the falsey-branch already thinks $a has a yes certainty because of processing happing in NodeScopeResolver, before TypeSpecifier Isset_ processing kicks in

example:

use function PHPStan\Testing\assertVariableCertainty;

function maybeCertainNull(): void
{
	if (rand() % 2) {
		$a = ['bar' => null];
		if (rand() % 3) {
			$a = ['bar' => 'hello'];
		}
	}

	assertVariableCertainty(TrinaryLogic::createMaybe(), $a);
	if (isset($a['bar'])) {
		assertVariableCertainty(TrinaryLogic::createYes(), $a);
	} else {
		assertVariableCertainty(TrinaryLogic::createMaybe(), $a); // Expected variable certainty Maybe, actual: Yes 
	}

	assertVariableCertainty(TrinaryLogic::createMaybe(), $a);
}

thats a problem we have already before this PR, and I don't know whether fixing it here with all the other changes needed would be a good thing as this PR will be already pretty large without this topic.

do you have an idea how to fix that problem? I don't have time right now to also dive into this new side-quest.

@staabm
Copy link
Contributor Author

staabm commented Oct 8, 2023

after a break and a short walk, I realized how it could be fixed: see #2666

@staabm staabm force-pushed the bug9908 branch 2 times, most recently from e32d61d to 139dbc2 Compare October 30, 2023 20:30
@staabm
Copy link
Contributor Author

staabm commented Oct 31, 2023

I feel I am close to something useful :-)

one TypeSpecifierTest is failling, because when merging left/right types the SpecifiedTypes intersection/union does not realize that __phpstanIssetExpr($array) and $array belong to the same expression and need to be merged

grafik

@ondrejmirtes
Copy link
Member

I''d say that when you're creating new IssetExpr in TypeSpecifier, you also need to say at the same time, what it means for the expression, and specify its type too in the same operation.

@ondrejmirtes
Copy link
Member

Maybe start a new PR instead of reviving this ancient one :)

@staabm staabm closed this Nov 28, 2023
@staabm staabm deleted the bug9908 branch November 28, 2023 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants