Skip to content
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ parameters:
reportMethodPurityOverride: true
checkDynamicConstantNameValues: true
unusedLabel: true
arrayColumnObjectArrays: true
7 changes: 7 additions & 0 deletions conf/config.level5.neon
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ conditionalTags:
phpstan.rules.rule: %featureToggles.checkPrintfParameterTypes%
PHPStan\Rules\DateIntervalInstantiationRule:
phpstan.rules.rule: %featureToggles.checkDateIntervalConstructor%
PHPStan\Rules\Functions\ArrayColumnRule:
phpstan.rules.rule: %featureToggles.arrayColumnObjectArrays%

autowiredAttributeServices:
# registers rules with #[RegisteredRule] attribute
Expand All @@ -26,3 +28,8 @@ services:
checkStrictPrintfPlaceholderTypes: %checkStrictPrintfPlaceholderTypes%
-
class: PHPStan\Rules\DateIntervalInstantiationRule
-
class: PHPStan\Rules\Functions\ArrayColumnRule
arguments:
treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain%
treatPhpDocTypesAsCertainTip: %tips.treatPhpDocTypesAsCertain%
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ parameters:
reportMethodPurityOverride: false
checkDynamicConstantNameValues: false
unusedLabel: false
arrayColumnObjectArrays: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ parametersSchema:
reportMethodPurityOverride: bool()
checkDynamicConstantNameValues: bool()
unusedLabel: bool()
arrayColumnObjectArrays: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
162 changes: 162 additions & 0 deletions src/Rules/Functions/ArrayColumnRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;
use function count;
use function sprintf;

/**
* Reports `array_column()` calls reading a property that does not exist on the
* objects contained in the source array.
*
* @implements Rule<FuncCall>
*/
final class ArrayColumnRule implements Rule
{

public function __construct(
private readonly ReflectionProvider $reflectionProvider,
private readonly bool $treatPhpDocTypesAsCertain,
private readonly bool $treatPhpDocTypesAsCertainTip,
)
{
}

public function getNodeType(): string
{
return FuncCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
if (!($node->name instanceof Node\Name)) {
return [];
}

if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
return [];
}

$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
if ($functionReflection->getName() !== 'array_column') {
return [];
}

$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$node->getArgs(),
$functionReflection->getVariants(),
$functionReflection->getNamedArgumentsVariants(),
);

$normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node);
Comment thread
staabm marked this conversation as resolved.
if ($normalizedFuncCall === null) {
return [];
}

$args = $normalizedFuncCall->getArgs();
if (count($args) < 2) {
return [];
}
Comment thread
staabm marked this conversation as resolved.

$arrayArg = $args[0]->value;
$valueType = $scope->getType($arrayArg)->getIterableValueType();
$nativeValueType = $scope->getNativeType($arrayArg)->getIterableValueType();

$errors = [];
foreach ($this->checkColumn($args[1]->value, $valueType, $nativeValueType, '#2 $column_key', $scope) as $error) {
$errors[] = $error;
}

if (count($args) >= 3) {
foreach ($this->checkColumn($args[2]->value, $valueType, $nativeValueType, '#3 $index_key', $scope) as $error) {
$errors[] = $error;
}
}

return $errors;
}

/**
* @return list<IdentifierRuleError>
*/
private function checkColumn(Node\Expr $columnExpr, Type $valueType, Type $nativeValueType, string $parameter, Scope $scope): array
{
$checkedValueType = $this->treatPhpDocTypesAsCertain ? $valueType : $nativeValueType;

// array_column() reads object properties (never ArrayAccess offsets), so
// only check when the elements are definitely objects. Array elements use
// offset access, scalars never have the member - leave those to other rules.
if (!$checkedValueType->isObject()->yes()) {

Check warning on line 101 in src/Rules/Functions/ArrayColumnRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // array_column() reads object properties (never ArrayAccess offsets), so // only check when the elements are definitely objects. Array elements use // offset access, scalars never have the member - leave those to other rules. - if (!$checkedValueType->isObject()->yes()) { + if ($checkedValueType->isObject()->no()) { return []; }

Check warning on line 101 in src/Rules/Functions/ArrayColumnRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // array_column() reads object properties (never ArrayAccess offsets), so // only check when the elements are definitely objects. Array elements use // offset access, scalars never have the member - leave those to other rules. - if (!$checkedValueType->isObject()->yes()) { + if ($checkedValueType->isObject()->no()) { return []; }
return [];
}

$columnType = $scope->getType($columnExpr);
$propertyNames = $columnType->getConstantStrings();
if ($propertyNames === []) {
return [];
}

$errors = [];
foreach ($propertyNames as $propertyNameType) {
$propertyName = $propertyNameType->getValue();
if (!$this->isPropertyMissing($checkedValueType, $propertyName)) {
continue;
}

$errorBuilder = RuleErrorBuilder::message(sprintf(
'Parameter %s of function array_column expects a valid property name, %s given, but %s does not have such property.',
$parameter,
$propertyNameType->describe(VerbosityLevel::value()),
$checkedValueType->describe(VerbosityLevel::typeOnly()),
))->identifier('arrayColumn.property');

if ($this->treatPhpDocTypesAsCertain && $this->treatPhpDocTypesAsCertainTip) {
if (!$nativeValueType->isObject()->yes() || !$this->isPropertyMissing($nativeValueType, $propertyName)) {

Check warning on line 126 in src/Rules/Functions/ArrayColumnRule.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ ))->identifier('arrayColumn.property'); if ($this->treatPhpDocTypesAsCertain && $this->treatPhpDocTypesAsCertainTip) { - if (!$nativeValueType->isObject()->yes() || !$this->isPropertyMissing($nativeValueType, $propertyName)) { + if ($nativeValueType->isObject()->no() || !$this->isPropertyMissing($nativeValueType, $propertyName)) { $errorBuilder->treatPhpDocTypesAsCertainTip(); } }
$errorBuilder->treatPhpDocTypesAsCertainTip();
}
}

$errors[] = $errorBuilder->build();
}

return $errors;
}

private function isPropertyMissing(Type $valueType, string $propertyName): bool
{
$classReflections = $valueType->getObjectClassReflections();
if ($classReflections === []) {
return false;
}

foreach ($classReflections as $classReflection) {
if ($classReflection->isEnum()) {
return false;
}
if ($classReflection->hasInstanceProperty($propertyName)) {
return false;
}
if ($classReflection->allowsDynamicProperties()) {
return false;
}
if ($classReflection->hasNativeMethod('__isset') && $classReflection->hasNativeMethod('__get')) {
return false;
}
}

return true;
}

}
61 changes: 61 additions & 0 deletions tests/PHPStan/Rules/Functions/ArrayColumnRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<ArrayColumnRule>
*/
class ArrayColumnRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new ArrayColumnRule(
self::createReflectionProvider(),
$this->shouldTreatPhpDocTypesAsCertain(),
true,
);
}

#[RequiresPhp('>= 8.2')]
public function testRule(): void
{
$tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.';
$this->analyse([__DIR__ . '/data/array-column.php'], [
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'wrong_key' given, but ArrayColumnRuleTest\\NonFinalObject does not have such property.",
64,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
68,
$tipText,
],
[
"Parameter #3 \$index_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
70,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
71,
$tipText,
],
[
"Parameter #3 \$index_key of function array_column expects a valid property name, 'missing2' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
71,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'wrong_key' given, but ArrayColumnRuleTest\\NonFinalObject does not have such property.",
96,
],
]);
}

}
98 changes: 98 additions & 0 deletions tests/PHPStan/Rules/Functions/data/array-column.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php declare(strict_types = 1); // lint >= 8.2

namespace ArrayColumnRuleTest;

class NonFinalObject
{

/** @var string */
public $key = 'as';

}

final class FinalObject
{

public int $id = 1;

public string $name = 'a';

private int $secret = 2;

}

class MagicObject
{

public function __get(string $name): int
{
return 1;
}

public function __isset(string $name): bool
{
return true;
}

}

#[\AllowDynamicProperties]
class DynamicObject
{

}

enum Suit: string
{

case Hearts = 'H';

}

/**
* @param NonFinalObject[] $a
* @param FinalObject[] $b
Comment thread
staabm marked this conversation as resolved.
* @param MagicObject[] $c
* @param DynamicObject[] $d
* @param Suit[] $e
* @param array<array<string, int>> $f
* @param list<FinalObject|array<string, int>> $g
*/
function test(array $a, array $b, array $c, array $d, array $e, array $f, array $g): void
{
array_column($a, 'key');
array_column($a, 'wrong_key');

array_column($b, 'id');
array_column($b, 'name');
array_column($b, 'missing');
array_column($b, 'name', 'id');
array_column($b, 'name', 'missing');
array_column($b, 'missing', 'missing2');
array_column($b, 'secret');

array_column($c, 'anything');
array_column($d, 'anything');

array_column($e, 'value');
array_column($e, 'name');
array_column($e, 'missing');

array_column($f, 'col');
array_column($g, 'missing');
}

/**
* @param FinalObject[] $b
*/
function dynamicColumnName(array $b, string $column): void
{
array_column($b, $column);
}

function bug5101(): void
{
$ar = [new NonFinalObject(), new NonFinalObject()];
array_column($ar, 'wrong_key');
array_column($ar, 'key');
}
Loading