Skip to content

Commit 2b4e252

Browse files
phpstan-botclaude
andcommitted
Resolve array_column property checks through ArrayColumnHelper
Move the object-property-missing detection used by ArrayColumnRule into ArrayColumnHelper, the single home for array_column() semantics, and have the rule delegate to it instead of duplicating the property resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4ab7169 commit 2b4e252

3 files changed

Lines changed: 81 additions & 43 deletions

File tree

src/Rules/Functions/ArrayColumnRule.php

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPStan\Rules\IdentifierRuleError;
1212
use PHPStan\Rules\Rule;
1313
use PHPStan\Rules\RuleErrorBuilder;
14+
use PHPStan\Type\Php\ArrayColumnHelper;
1415
use PHPStan\Type\Type;
1516
use PHPStan\Type\VerbosityLevel;
1617
use function count;
@@ -27,6 +28,7 @@ final class ArrayColumnRule implements Rule
2728

2829
public function __construct(
2930
private readonly ReflectionProvider $reflectionProvider,
31+
private readonly ArrayColumnHelper $arrayColumnHelper,
3032
private readonly bool $treatPhpDocTypesAsCertain,
3133
private readonly bool $treatPhpDocTypesAsCertainTip,
3234
)
@@ -95,37 +97,32 @@ private function checkColumn(Node\Expr $columnExpr, Type $valueType, Type $nativ
9597
{
9698
$checkedValueType = $this->treatPhpDocTypesAsCertain ? $valueType : $nativeValueType;
9799

98-
// array_column() reads object properties (never ArrayAccess offsets), so
99-
// only check when the elements are definitely objects. Array elements use
100-
// offset access, scalars never have the member - leave those to other rules.
101-
if (!$checkedValueType->isObject()->yes()) {
100+
$columnType = $scope->getType($columnExpr);
101+
$missingProperties = $this->arrayColumnHelper->findMissingObjectProperties($checkedValueType, $columnType);
102+
if ($missingProperties === []) {
102103
return [];
103104
}
104105

105-
$columnType = $scope->getType($columnExpr);
106-
$propertyNames = $columnType->getConstantStrings();
107-
if ($propertyNames === []) {
108-
return [];
106+
$nativeMissingPropertyNames = [];
107+
foreach ($this->arrayColumnHelper->findMissingObjectProperties($nativeValueType, $columnType) as $nativeMissingProperty) {
108+
$nativeMissingPropertyNames[$nativeMissingProperty->getValue()] = true;
109109
}
110110

111111
$errors = [];
112-
foreach ($propertyNames as $propertyNameType) {
113-
$propertyName = $propertyNameType->getValue();
114-
if (!$this->isPropertyMissing($checkedValueType, $propertyName)) {
115-
continue;
116-
}
117-
112+
foreach ($missingProperties as $propertyNameType) {
118113
$errorBuilder = RuleErrorBuilder::message(sprintf(
119114
'Parameter %s of function array_column expects a valid property name, %s given, but %s does not have such property.',
120115
$parameter,
121116
$propertyNameType->describe(VerbosityLevel::value()),
122117
$checkedValueType->describe(VerbosityLevel::typeOnly()),
123118
))->identifier('arrayColumn.property');
124119

125-
if ($this->treatPhpDocTypesAsCertain && $this->treatPhpDocTypesAsCertainTip) {
126-
if (!$nativeValueType->isObject()->yes() || !$this->isPropertyMissing($nativeValueType, $propertyName)) {
127-
$errorBuilder->treatPhpDocTypesAsCertainTip();
128-
}
120+
if (
121+
$this->treatPhpDocTypesAsCertain
122+
&& $this->treatPhpDocTypesAsCertainTip
123+
&& !isset($nativeMissingPropertyNames[$propertyNameType->getValue()])
124+
) {
125+
$errorBuilder->treatPhpDocTypesAsCertainTip();
129126
}
130127

131128
$errors[] = $errorBuilder->build();
@@ -134,29 +131,4 @@ private function checkColumn(Node\Expr $columnExpr, Type $valueType, Type $nativ
134131
return $errors;
135132
}
136133

137-
private function isPropertyMissing(Type $valueType, string $propertyName): bool
138-
{
139-
$classReflections = $valueType->getObjectClassReflections();
140-
if ($classReflections === []) {
141-
return false;
142-
}
143-
144-
foreach ($classReflections as $classReflection) {
145-
if ($classReflection->isEnum()) {
146-
return false;
147-
}
148-
if ($classReflection->hasInstanceProperty($propertyName)) {
149-
return false;
150-
}
151-
if ($classReflection->allowsDynamicProperties()) {
152-
return false;
153-
}
154-
if ($classReflection->hasNativeMethod('__isset') && $classReflection->hasNativeMethod('__get')) {
155-
return false;
156-
}
157-
}
158-
159-
return true;
160-
}
161-
162134
}

src/Type/Php/ArrayColumnHelper.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPStan\Type\ArrayType;
1212
use PHPStan\Type\Constant\ConstantArrayType;
1313
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
14+
use PHPStan\Type\Constant\ConstantStringType;
1415
use PHPStan\Type\IntegerType;
1516
use PHPStan\Type\MixedType;
1617
use PHPStan\Type\NeverType;
@@ -150,6 +151,69 @@ public function handleConstantArray(ConstantArrayType $arrayType, Type $columnTy
150151
return $builder->getArray();
151152
}
152153

154+
/**
155+
* Returns the property names from $columnType that the objects contained in
156+
* $iterableValueType certainly lack, so that reading them via array_column()
157+
* would yield nothing.
158+
*
159+
* Unlike the type-inference path (getOffsetOrProperty), this resolves
160+
* properties at the ClassReflection level so that concrete and non-final
161+
* element classes are reported - matching how AccessPropertiesRule reports
162+
* direct property fetches.
163+
*
164+
* @return list<ConstantStringType>
165+
*/
166+
public function findMissingObjectProperties(Type $iterableValueType, Type $columnType): array
167+
{
168+
// array_column() reads object properties (never ArrayAccess offsets), so
169+
// only check when the elements are definitely objects. Array elements use
170+
// offset access, scalars never have the member - leave those to other rules.
171+
if (!$iterableValueType->isObject()->yes()) {
172+
return [];
173+
}
174+
175+
$propertyNameTypes = $columnType->getConstantStrings();
176+
if ($propertyNameTypes === []) {
177+
return [];
178+
}
179+
180+
$missing = [];
181+
foreach ($propertyNameTypes as $propertyNameType) {
182+
if (!$this->isObjectPropertyMissing($iterableValueType, $propertyNameType->getValue())) {
183+
continue;
184+
}
185+
186+
$missing[] = $propertyNameType;
187+
}
188+
189+
return $missing;
190+
}
191+
192+
private function isObjectPropertyMissing(Type $iterableValueType, string $propertyName): bool
193+
{
194+
$classReflections = $iterableValueType->getObjectClassReflections();
195+
if ($classReflections === []) {
196+
return false;
197+
}
198+
199+
foreach ($classReflections as $classReflection) {
200+
if ($classReflection->isEnum()) {
201+
return false;
202+
}
203+
if ($classReflection->hasInstanceProperty($propertyName)) {
204+
return false;
205+
}
206+
if ($classReflection->allowsDynamicProperties()) {
207+
return false;
208+
}
209+
if ($classReflection->hasNativeMethod('__isset') && $classReflection->hasNativeMethod('__get')) {
210+
return false;
211+
}
212+
}
213+
214+
return true;
215+
}
216+
153217
/**
154218
* @return array{Type, TrinaryLogic}
155219
*/

tests/PHPStan/Rules/Functions/ArrayColumnRuleTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PHPStan\Rules\Rule;
66
use PHPStan\Testing\RuleTestCase;
7+
use PHPStan\Type\Php\ArrayColumnHelper;
78
use PHPUnit\Framework\Attributes\RequiresPhp;
89

910
/**
@@ -16,6 +17,7 @@ protected function getRule(): Rule
1617
{
1718
return new ArrayColumnRule(
1819
self::createReflectionProvider(),
20+
self::getContainer()->getByType(ArrayColumnHelper::class),
1921
$this->shouldTreatPhpDocTypesAsCertain(),
2022
true,
2123
);

0 commit comments

Comments
 (0)