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

Add has-property- assertion #9324

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Psalm/Codebase.php
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,22 @@ public function getFunctionLikeStorage(
return $this->functions->getStorage($statements_analyzer, strtolower($function_id));
}

/**
* Whether or not a given property exists
*/
public function propertyExists(
string $property_id,
?CodeLocation $code_location = null
): bool {
return $this->properties->propertyExists(
$property_id,
true, // $read_mode, not sure about this
null,
null,
$code_location,
);
}

/**
* Whether or not a given method exists
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use Psalm\Storage\Assertion\HasAtLeastCount;
use Psalm\Storage\Assertion\HasExactCount;
use Psalm\Storage\Assertion\HasMethod;
use Psalm\Storage\Assertion\HasProperty;
use Psalm\Storage\Assertion\InArray;
use Psalm\Storage\Assertion\IsAClass;
use Psalm\Storage\Assertion\IsClassEqual;
Expand Down Expand Up @@ -948,6 +949,14 @@ public static function processFunctionCall(
if ($first_var_name) {
$if_types[$first_var_name] = [[new HasMethod($expr->getArgs()[1]->value->value)]];
}
} elseif ($expr->name instanceof PhpParser\Node\Name
&& strtolower($expr->name->parts[0]) === 'property_exists'
&& isset($expr->getArgs()[1])
&& $expr->getArgs()[1]->value instanceof PhpParser\Node\Scalar\String_
) {
if ($first_var_name) {
$if_types[$first_var_name] = [[new HasProperty($expr->getArgs()[1]->value->value)]];
}
} elseif (self::hasInArrayCheck($expr) && $source instanceof StatementsAnalyzer) {
return self::getInarrayAssertions($expr, $source, $first_var_name);
} elseif (self::hasArrayKeyExistsCheck($expr)) {
Expand Down
128 changes: 128 additions & 0 deletions src/Psalm/Internal/Type/SimpleAssertionReconciler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Psalm\Storage\Assertion\HasExactCount;
use Psalm\Storage\Assertion\HasIntOrStringArrayAccess;
use Psalm\Storage\Assertion\HasMethod;
use Psalm\Storage\Assertion\HasProperty;
use Psalm\Storage\Assertion\HasStringArrayAccess;
use Psalm\Storage\Assertion\InArray;
use Psalm\Storage\Assertion\IsCountable;
Expand Down Expand Up @@ -286,6 +287,19 @@ public static function reconcile(
);
}

if ($assertion instanceof HasProperty) {
return self::reconcileHasProperty(
$assertion,
$codebase,
$existing_var_type,
$key,
$negated,
$code_location,
$suppressed_issues,
$failed_reconciliation,
);
}

$assertion_type = $assertion->getAtomicType();

if ($assertion_type instanceof TObject) {
Expand Down Expand Up @@ -967,6 +981,120 @@ private static function reconcileHasMethod(
: Type::getNever();
}

/**
* @param string[] $suppressed_issues
* @param Reconciler::RECONCILIATION_* $failed_reconciliation
*/
private static function reconcileHasProperty(
HasProperty $assertion,
Codebase $codebase,
Union $existing_var_type,
?string $key,
bool $negated,
?CodeLocation $code_location,
array $suppressed_issues,
int &$failed_reconciliation
): Union {
$property_name = $assertion->property_name;

$old_var_type_string = $existing_var_type->getId();
$existing_var_atomic_types = $existing_var_type->getAtomicTypes();

$object_types = [];
$redundant = true;

foreach ($existing_var_atomic_types as $type) {
if ($type instanceof TNamedObject
&& $codebase->classOrInterfaceExists($type->value)
) {
if (!$codebase->propertyExists($type->value . '::$' . $property_name)) {
$match_found = false;

$extra_types = $type->extra_types;
foreach ($type->extra_types as $k => $extra_type) {
if ($extra_type instanceof TNamedObject
&& $codebase->classOrInterfaceExists($extra_type->value)
&& $codebase->propertyExists($extra_type->value . '::$' . $property_name)
) {
$match_found = true;
} elseif ($extra_type instanceof TObjectWithProperties) {
$match_found = true;

if (!isset($extra_type->properties[$property_name])) {
unset($extra_types[$k]);
$extra_type = $extra_type->setProperties(array_merge(
$extra_type->properties,
[$property_name => Type::getMixed()],
));
$extra_types[$extra_type->getKey()] = $extra_type;
$redundant = false;
}
}
}

if (!$match_found) {
$extra_type = new TObjectWithProperties(
[$property_name => Type::getMixed()],
);
$extra_types[$extra_type->getKey()] = $extra_type;
$redundant = false;
}

$type = $type->setIntersectionTypes($extra_types);
}
$object_types[] = $type;
} elseif ($type instanceof TObjectWithProperties) {
if (!isset($type->properties[$property_name])) {
$type = $type->setProperties(array_merge(
$type->properties,
[$property_name => Type::getMixed()],
));
$redundant = false;
}
$object_types[] = $type;
} elseif ($type instanceof TObject || $type instanceof TMixed) {
$object_types[] = new TObjectWithProperties(
[$property_name => Type::getMixed()],
);
$redundant = false;
} elseif ($type instanceof TString) {
// we don’t know
$object_types[] = $type;
$redundant = false;
} elseif ($type instanceof TTemplateParam) {
$object_types[] = $type;
$redundant = false;
} else {
$redundant = false;
}
}

if (!$object_types || $redundant) {
if ($key && $code_location) {
self::triggerIssueForImpossible(
$existing_var_type,
$old_var_type_string,
$key,
$assertion,
$redundant,
$negated,
$code_location,
$suppressed_issues,
);
}
}

if ($object_types) {
return new Union($object_types);
}

$failed_reconciliation = Reconciler::RECONCILIATION_EMPTY;

return $existing_var_type->from_docblock
? new Union([new TEmptyMixed()])
: Type::getNever();
}

/**
* @param string[] $suppressed_issues
* @param Reconciler::RECONCILIATION_* $failed_reconciliation
Expand Down
33 changes: 33 additions & 0 deletions src/Psalm/Storage/Assertion/DoesNotHaveProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Psalm\Storage\Assertion;

use Psalm\Storage\Assertion;

/**
* @psalm-immutable
*/
final class DoesNotHaveProperty extends Assertion
{
public string $property_name;

public function __construct(string $property_name)
{
$this->property_name = $property_name;
}

public function getNegation(): Assertion
{
return new HasProperty($this->property_name);
}

public function __toString(): string
{
return '!has-property-' . $this->property_name;
}

public function isNegationOf(Assertion $assertion): bool
{
return $assertion instanceof HasProperty && $this->property_name === $assertion->property_name;
}
}
33 changes: 33 additions & 0 deletions src/Psalm/Storage/Assertion/HasProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Psalm\Storage\Assertion;

use Psalm\Storage\Assertion;

/**
* @psalm-immutable
*/
final class HasProperty extends Assertion
{
public string $property_name;

public function __construct(string $property_name)
{
$this->property_name = $property_name;
}

public function getNegation(): Assertion
{
return new DoesNotHaveProperty($this->property_name);
}

public function __toString(): string
{
return 'has-property-' . $this->property_name;
}

public function isNegationOf(Assertion $assertion): bool
{
return $assertion instanceof DoesNotHaveProperty && $this->property_name === $assertion->property_name;
}
}
15 changes: 15 additions & 0 deletions tests/PropertyTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2687,6 +2687,21 @@ class Baz
'ignored_issues' => [],
'php_version' => '8.1',
],
'propertyExists' => [
'code' => <<<'PHP'
<?php
class A {}
$a = new A;

$z = null;
if (property_exists($a, "foo")) {
$z = $a;
}
PHP,
'assertions' => [
'$z===' => 'A&object{foo:mixed}|null',
],
],
];
}

Expand Down