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
140 changes: 140 additions & 0 deletions src/Rules/Functions/ArrayColumnRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?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\Php\ArrayColumnHelper;
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 ArrayColumnHelper $arrayColumnHelper,
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 [];
}

// array_column() requires at least the array and the column key - bail
// out before the more expensive reflection lookups below.
if (count($node->getArgs()) < 2) {
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;

$columnType = $scope->getType($columnExpr);
$missingProperties = $this->arrayColumnHelper->findMissingObjectProperties($checkedValueType, $columnType);
if ($missingProperties === []) {
return [];
}

$nativeMissingPropertyNames = [];
foreach ($this->arrayColumnHelper->findMissingObjectProperties($nativeValueType, $columnType) as $nativeMissingProperty) {
$nativeMissingPropertyNames[$nativeMissingProperty->getValue()] = true;
}

$errors = [];
foreach ($missingProperties as $propertyNameType) {
$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
&& !isset($nativeMissingPropertyNames[$propertyNameType->getValue()])
) {
$errorBuilder->treatPhpDocTypesAsCertainTip();
}

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

return $errors;
}

}
72 changes: 72 additions & 0 deletions src/Type/Php/ArrayColumnHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
Expand Down Expand Up @@ -150,6 +151,77 @@
return $builder->getArray();
}

/**
* Returns the property names from $columnType that the objects contained in
* $iterableValueType certainly lack, so that reading them via array_column()
* would yield nothing.
*
* Unlike the type-inference path (getOffsetOrProperty), this resolves
* properties at the ClassReflection level so that concrete and non-final
* element classes are reported - matching how AccessPropertiesRule reports
* direct property fetches.
*
* @return list<ConstantStringType>
*/
public function findMissingObjectProperties(Type $iterableValueType, Type $columnType): array
{
// 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 (!$iterableValueType->isObject()->yes()) {

Check warning on line 171 in src/Type/Php/ArrayColumnHelper.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 (!$iterableValueType->isObject()->yes()) { + if ($iterableValueType->isObject()->no()) { return []; }

Check warning on line 171 in src/Type/Php/ArrayColumnHelper.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 (!$iterableValueType->isObject()->yes()) { + if ($iterableValueType->isObject()->no()) { return []; }
return [];
}

$propertyNameTypes = $columnType->getConstantStrings();
if ($propertyNameTypes === []) {
return [];
}

$missing = [];
foreach ($propertyNameTypes as $propertyNameType) {
if (!$this->isObjectPropertyMissing($iterableValueType, $propertyNameType->getValue())) {
continue;
}

$missing[] = $propertyNameType;
}

return $missing;
}

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

foreach ($classReflections as $classReflection) {
if ($classReflection->isEnum()) {
// Enum cases expose the read-only `name` property, and backed
// enums additionally expose `value`. Any other name is missing.
if ($propertyName === 'name') {
return false;
}
if ($propertyName === 'value' && $classReflection->isBackedEnum()) {
return false;
}
continue;
}
if ($classReflection->hasInstanceProperty($propertyName)) {
return false;
}
if ($classReflection->allowsDynamicProperties()) {
return false;
}
if ($classReflection->hasNativeMethod('__isset') && $classReflection->hasNativeMethod('__get')) {
return false;
}
}

return true;
}

/**
* @return array{Type, TrinaryLogic}
*/
Expand Down
103 changes: 103 additions & 0 deletions tests/PHPStan/Rules/Functions/ArrayColumnRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

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

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

protected function getRule(): Rule
{
return new ArrayColumnRule(
self::createReflectionProvider(),
self::getContainer()->getByType(ArrayColumnHelper::class),
$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.",
72,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
76,
$tipText,
],
[
"Parameter #3 \$index_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
78,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
79,
$tipText,
],
[
"Parameter #3 \$index_key of function array_column expects a valid property name, 'missing2' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
79,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\Suit does not have such property.",
87,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'value' given, but ArrayColumnRuleTest\\PureSuit does not have such property.",
93,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\PureSuit does not have such property.",
94,
$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.",
108,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject|ArrayColumnRuleTest\\NonFinalObject does not have such property.",
119,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'Price' given, but DateTimeImmutable does not have such property.",
130,
$tipText,
],
[
"Parameter #2 \$column_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
140,
$tipText,
],
[
"Parameter #3 \$index_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
141,
$tipText,
],
[
"Parameter #3 \$index_key of function array_column expects a valid property name, 'missing' given, but ArrayColumnRuleTest\\FinalObject does not have such property.",
142,
$tipText,
],
]);
}

}
Loading
Loading