Skip to content

Commit 2cf4368

Browse files
authored
fix: processing expressions in ActiveRecordAttributeArrayAnalyzer (#54)
1 parent 0c6e437 commit 2cf4368

8 files changed

Lines changed: 51 additions & 26 deletions

File tree

README.md

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -228,22 +228,24 @@ Customer::find()->with('orders.oops')->all(); // ✗ typo — no such rela
228228

229229
#### Active Record condition validation
230230

231-
`findOne()`, `findAll()`, and `deleteAll()` take a plain array condition (`['attribute' => value]`, with an array value matched as an `IN (...)` condition) — and so does the second, condition argument of `updateAll()` / `updateAllCounters()`. Like `attributeLabels()` and `scenarios()`, this is never checked against the model until the query actually runs. This rule checks that every attribute name in a condition array exists on the queried ActiveRecord model (the same `@property`-aware resolution as `activeRecordRelationValidation`) and that its value's type is compatible with the attribute's declared type. Only array literals with a resolvable string key are checked; primary-key-only lookups (`findOne(1)`, `findOne([1, 2])`) and dynamically-built condition arrays are left alone.
231+
`findOne()`, `findAll()`, and `deleteAll()` take a plain array condition (`['attribute' => value]`, with an array value matched as an `IN (...)` condition) — and so does the second, condition argument of `updateAll()` / `updateAllCounters()`. Like `attributeLabels()` and `scenarios()`, this is never checked against the model until the query actually runs. This rule checks that every attribute name in a condition array exists on the queried ActiveRecord model (the same `@property`-aware resolution as `activeRecordRelationValidation`) and that its value's type is compatible with the attribute's declared type. Only array literals with a resolvable string key are checked; primary-key-only lookups (`findOne(1)`, `findOne([1, 2])`) and dynamically-built condition arrays are left alone. A value implementing `yii\db\ExpressionInterface` (e.g. `new Expression('NOW()')`) is accepted for any attribute regardless of its declared type — `yii\db\conditions\HashConditionBuilder` builds it as raw SQL instead of type-casting it, and does so per-value inside an `IN (...)` array too.
232232

233233
```php
234234
/**
235235
* @property int $id
236236
* @property int $status
237+
* @property string $updated_at
237238
*/
238239
final class Customer extends ActiveRecord { /* ... */ }
239240

240-
Customer::findOne(1); // ✓ primary key lookup, not a condition hash
241-
Customer::findOne(['status' => 1]); // ✓
242-
Customer::findOne(['status' => [1, 2]]); // ✓ IN (1, 2)
243-
Customer::findOne(['statuss' => 1]); // ✗ typo — unknown attribute
244-
Customer::findOne(['status' => '1']); // ✗ wrong type — int expected
245-
Customer::deleteAll(['statuss' => 1]); // ✗ typo — unknown attribute
246-
Customer::updateAll(['status' => 1], ['idd' => 5]); // ✗ typo — unknown attribute in the condition
241+
Customer::findOne(1); // ✓ primary key lookup, not a condition hash
242+
Customer::findOne(['status' => 1]); // ✓
243+
Customer::findOne(['status' => [1, 2]]); // ✓ IN (1, 2)
244+
Customer::findOne(['updated_at' => new Expression('NOW()')]); // ✓ raw SQL, not type-checked
245+
Customer::findOne(['statuss' => 1]); // ✗ typo — unknown attribute
246+
Customer::findOne(['status' => '1']); // ✗ wrong type — int expected
247+
Customer::deleteAll(['statuss' => 1]); // ✗ typo — unknown attribute
248+
Customer::updateAll(['status' => 1], ['idd' => 5]); // ✗ typo — unknown attribute in the condition
247249
```
248250

249251
#### Active Record relations validation
@@ -296,21 +298,23 @@ final class OrderItem extends ActiveRecord { /* ... */ }
296298

297299
#### Active Record update values validation
298300

299-
`updateAll()`'s attribute values and `updateAllCounters()`'s counter values are the other plain array these two methods take — the values written into the row, as opposed to the WHERE condition `activeRecordConditionValidation` checks. This rule checks that every attribute name exists on the ActiveRecord model and that its value's type is compatible with the attribute's declared type; unlike a condition, these values are written as-is, so (unlike `activeRecordConditionValidation`) an array value is not treated as an `IN (...)` shorthand and is always a type mismatch.
301+
`updateAll()`'s attribute values and `updateAllCounters()`'s counter values are the other plain array these two methods take — the values written into the row, as opposed to the WHERE condition `activeRecordConditionValidation` checks. This rule checks that every attribute name exists on the ActiveRecord model and that its value's type is compatible with the attribute's declared type; unlike a condition, these values are written as-is, so (unlike `activeRecordConditionValidation`) an array value is not treated as an `IN (...)` shorthand and is always a type mismatch. As with a condition, a value implementing `yii\db\ExpressionInterface` is accepted for any attribute regardless of its declared type — `yii\db\QueryBuilder::prepareUpdateSets()` builds it as raw SQL instead of type-casting it.
300302

301303
```php
302304
/**
303305
* @property int $id
304306
* @property int $status
305307
* @property int $age
308+
* @property string $updated_at
306309
*/
307310
final class Customer extends ActiveRecord { /* ... */ }
308311

309-
Customer::updateAll(['status' => 1], ['id' => 5]); // ✓
310-
Customer::updateAll(['statuss' => 1]); // ✗ typo — unknown attribute
311-
Customer::updateAll(['status' => 'active']); // ✗ wrong type — int expected
312-
Customer::updateAllCounters(['age' => 1]); // ✓
313-
Customer::updateAllCounters(['agee' => 1]); // ✗ typo — unknown attribute
312+
Customer::updateAll(['status' => 1], ['id' => 5]); // ✓
313+
Customer::updateAll(['updated_at' => new Expression('NOW()')]); // ✓ raw SQL, not type-checked
314+
Customer::updateAll(['statuss' => 1]); // ✗ typo — unknown attribute
315+
Customer::updateAll(['status' => 'active']); // ✗ wrong type — int expected
316+
Customer::updateAllCounters(['age' => 1]); // ✓
317+
Customer::updateAllCounters(['agee' => 1]); // ✗ typo — unknown attribute
314318
```
315319

316320
#### `BaseObject` instantiation validation

src/Analyzers/ActiveRecordAttributeArrayAnalyzer.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
use PHPStan\Reflection\ClassReflection;
1414
use PHPStan\Rules\IdentifierRuleError;
1515
use PHPStan\Type\MixedType;
16+
use PHPStan\Type\ObjectType;
17+
use PHPStan\Type\TypeCombinator;
1618
use PHPStan\Type\VerbosityLevel;
19+
use yii\db\ExpressionInterface;
1720

1821
final class ActiveRecordAttributeArrayAnalyzer
1922
{
@@ -121,7 +124,9 @@ private function validateAttributeValueType(
121124
return [];
122125
}
123126

124-
if (!$expectedType->accepts($actualType, true)->no()) {
127+
$acceptedType = TypeCombinator::union($expectedType, new ObjectType(ExpressionInterface::class));
128+
129+
if (!$acceptedType->accepts($actualType, true)->no()) {
125130
return [];
126131
}
127132

tests/Rules/ActiveRecordConditionValidationRuleTest.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ public function testRule(): void
1919
$this->analyse(
2020
[self::getDataFilePath('code')],
2121
[
22-
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in findOne() condition.', $customerClass), 25],
23-
[sprintf('Value for attribute "status" on ActiveRecord %s in findOne() condition must be int, string given.', $customerClass), 26],
22+
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in findOne() condition.', $customerClass), 26],
2423
[sprintf('Value for attribute "status" on ActiveRecord %s in findOne() condition must be int, string given.', $customerClass), 27],
25-
[sprintf('Unknown attribute "emial" for ActiveRecord %s in findAll() condition.', $customerClass), 28],
26-
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in deleteAll() condition.', $customerClass), 29],
27-
[sprintf('Unknown attribute "idd" for ActiveRecord %s in updateAll() condition.', $customerClass), 30],
28-
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in updateAllCounters() condition.', $customerClass), 31],
24+
[sprintf('Value for attribute "status" on ActiveRecord %s in findOne() condition must be int, string given.', $customerClass), 28],
25+
[sprintf('Unknown attribute "emial" for ActiveRecord %s in findAll() condition.', $customerClass), 29],
26+
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in deleteAll() condition.', $customerClass), 30],
27+
[sprintf('Unknown attribute "idd" for ActiveRecord %s in updateAll() condition.', $customerClass), 31],
28+
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in updateAllCounters() condition.', $customerClass), 32],
2929
],
3030
);
3131
}

tests/Rules/ActiveRecordUpdateValuesValidationRuleTest.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ public function testRule(): void
1919
$this->analyse(
2020
[self::getDataFilePath('code')],
2121
[
22-
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in updateAll() attributes.', $customerClass), 21],
23-
[sprintf('Value for attribute "status" on ActiveRecord %s in updateAll() attributes must be int, string given.', $customerClass), 22],
24-
[sprintf('Value for attribute "status" on ActiveRecord %s in updateAll() attributes must be int, array<int, int> given.', $customerClass), 23],
25-
[sprintf('Unknown attribute "agee" for ActiveRecord %s in updateAllCounters() counters.', $customerClass), 24],
26-
[sprintf('Value for attribute "age" on ActiveRecord %s in updateAllCounters() counters must be int, string given.', $customerClass), 25],
22+
[sprintf('Unknown attribute "statuss" for ActiveRecord %s in updateAll() attributes.', $customerClass), 22],
23+
[sprintf('Value for attribute "status" on ActiveRecord %s in updateAll() attributes must be int, string given.', $customerClass), 23],
24+
[sprintf('Value for attribute "status" on ActiveRecord %s in updateAll() attributes must be int, array<int, int> given.', $customerClass), 24],
25+
[sprintf('Unknown attribute "agee" for ActiveRecord %s in updateAllCounters() counters.', $customerClass), 25],
26+
[sprintf('Value for attribute "age" on ActiveRecord %s in updateAllCounters() counters must be int, string given.', $customerClass), 26],
2727
],
2828
);
2929
}

tests/Rules/Data/ActiveRecordConditionValidation/code.php

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

55
use MSpirkov\Yii2\PHPStan\Tests\Rules\Source\ActiveRecordConditionValidation\Customer;
66
use MSpirkov\Yii2\PHPStan\Tests\Rules\Source\ActiveRecordConditionValidation\NotActiveRecord;
7+
use yii\db\Expression;
78

89
final class ValidCustomerUsage
910
{
@@ -75,6 +76,14 @@ public function run(): void
7576

7677
// Attribute type is mixed — nothing meaningful to compare against.
7778
Customer::findOne(['extra' => 5]);
79+
80+
// ExpressionInterface bypasses dbTypecast in HashConditionBuilder::build(), so it's
81+
// valid for any attribute regardless of its declared type.
82+
Customer::findOne(['updated_at' => new Expression('NOW()')]);
83+
84+
// Same bypass applies per-value inside an IN condition — e.g. matching either
85+
// a dynamically configured default status or one of two explicit ones.
86+
Customer::findOne(['status' => [new Expression('(SELECT default_status FROM settings)'), 1, 2]]);
7887
}
7988

8089
private function findByDynamicKey(string $dynamicKey): void

tests/Rules/Data/ActiveRecordUpdateValuesValidation/code.php

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

55
use MSpirkov\Yii2\PHPStan\Tests\Rules\Source\ActiveRecordUpdateValuesValidation\Customer;
66
use MSpirkov\Yii2\PHPStan\Tests\Rules\Source\ActiveRecordUpdateValuesValidation\NotActiveRecord;
7+
use yii\db\Expression;
78

89
final class ValidCustomerUsage
910
{
@@ -48,5 +49,9 @@ public function run(): void
4849
$className::updateAll(['statuss' => 1]);
4950
$methodName = 'updateAll';
5051
Customer::$methodName(['statuss' => 1]);
52+
53+
// ExpressionInterface bypasses dbTypecast in QueryBuilder::prepareUpdateSets(), so
54+
// it's valid for any attribute regardless of its declared type.
55+
Customer::updateAll(['updated_at' => new Expression('NOW()')]);
5156
}
5257
}

tests/Rules/Source/ActiveRecordConditionValidation/Customer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* @property string $email
1212
* @property int $status
1313
* @property int $age
14+
* @property string $updated_at
1415
* @property-read string $displayName
1516
* @property mixed $extra
1617
*/

tests/Rules/Source/ActiveRecordUpdateValuesValidation/Customer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* @property int $id
1111
* @property int $status
1212
* @property int $age
13+
* @property string $updated_at
1314
*/
1415
final class Customer extends ActiveRecord
1516
{

0 commit comments

Comments
 (0)