Skip to content

Commit c7c4ccf

Browse files
authored
feat: add a rule for validating where methods in ActiveQuery (#50)
1 parent 9aa6612 commit c7c4ccf

9 files changed

Lines changed: 388 additions & 19 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Statically validate Yii2's loosely-typed config arrays and array-driven conventi
100100
| [`modelAttributeLabelsValidation`](#model-attribute-labels-validation) | `attributeLabels()` entries in `yii\base\Model` that target attributes that don't exist, or use an empty attribute name |
101101
| [`modelRulesValidation`](#model-validation-rules-validation) | Malformed or invalid `rules()` in `yii\base\Model` — unknown validators, missing required options, bad regexes, unknown attributes, and more |
102102
| [`modelScenariosValidation`](#model-scenarios-validation) | `scenarios()` entries in `yii\base\Model` with an empty name, a non-array attribute list, or an unknown attribute |
103+
| [`queryConditionValidation`](#query-condition-validation) | `where()` / `andWhere()` / `orWhere()` operator-format conditions (`in`, `between`, `like`, etc.) with the wrong number of operands |
103104
| [`uploadedFileInstanceValidation`](#uploadedfile-instance-validation) | `UploadedFile::getInstance()` / `getInstances()` calls referencing an attribute that does not exist on the given model |
104105
| [`widgetPropertiesValidation`](#widget-properties-validation) | Unknown or mistyped option keys and bad option types in `Widget::begin()` / `Widget::widget()` config arrays |
105106
| [`yiiCreateObjectValidation`](#yiicreateobject-validation) | `Yii::createObject()` config arrays missing `class`/`__class`, bad config keys, and bad option types |
@@ -486,6 +487,22 @@ final class ContactModel extends Model
486487
}
487488
```
488489

490+
#### Query condition validation
491+
492+
`Query::where()` / `andWhere()` / `orWhere()` accept an "operator format" array (`[operator, operand1, operand2, ...]`), and Yii only discovers a missing operand at query-build time — each `yii\db\conditions\*Condition::fromArrayDefinition()` throws an `InvalidArgumentException` if its required operands aren't present. This rule checks the operand count against those same rules: `not`, `between` / `not between`, `in` / `not in`, `like` and its variants, and `exists` / `not exists` each need a specific minimum (or, for `not`, an exact) number of operands, and the standard comparison operators (`=`, `!=`, `<>`, `>`, `>=`, `<`, `<=`) need exactly 2, Yii's documented "arbitrary operator" case. `and` / `or` operands are recursed into, since they typically wrap further operator-format sub-conditions; `yii\db\conditions\ConjunctionCondition` itself never validates their count, but a zero-operand `and`/`or` can never produce a meaningful condition, so this rule still requires at least one. Any other operator string — a genuinely custom one registered via `QueryBuilder::setConditionClasses()` — is left unchecked rather than guessed at, and so is anything built dynamically or in hash format (`['status' => 1]`, never operator-format to begin with).
493+
494+
```php
495+
$query->where(['in', 'status']); // ✗ missing the values operand — needs 2
496+
$query->andWhere(['between', 'age', 18]); // ✗ missing the upper bound — needs 3
497+
$query->orWhere(['not', ['in', 'status']]); // ✗ same as above, nested inside "not"
498+
$query->where(['>=', 'age', 18, 30]); // ✗ arbitrary operator, extra operand — needs exactly 2
499+
$query->where(['and']); // ✗ empty "and" — needs at least 1 operand
500+
501+
$query->where(['in', 'status', [1, 2]]); // ✓
502+
$query->andWhere(['between', 'age', 18, 65]); // ✓
503+
$query->orWhere(['and', ['status' => 1], ['in', 'type', [1, 2]]]); // ✓
504+
```
505+
489506
#### `UploadedFile` instance validation
490507

491508
`UploadedFile::getInstance($model, $attribute)` and `getInstances($model, $attribute)` build the file input's name from `$model` and a plain attribute-name string, the same way `ActiveForm::field()` does — so a typo silently returns `null` (or an empty array) instead of the uploaded file. This rule checks that the attribute exists on the given model, the same `@property`-aware resolution used elsewhere (e.g. `activeFormFieldValidation`, `modelAttributeLabelsValidation`). `yii\base\DynamicModel` instances are skipped, since their attributes are defined at runtime via `defineAttribute()` and can't be resolved statically.

rules.neon

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ parameters:
3939
customValidators: []
4040
modelScenariosValidation:
4141
enabled: %mspirkovYii2Rules.enableValidationRules%
42+
queryConditionValidation:
43+
enabled: %mspirkovYii2Rules.enableValidationRules%
4244
uploadedFileInstanceValidation:
4345
enabled: %mspirkovYii2Rules.enableValidationRules%
4446
widgetPropertiesValidation:
@@ -127,6 +129,9 @@ parametersSchema:
127129
modelScenariosValidation: structure([
128130
enabled: bool()
129131
])
132+
queryConditionValidation: structure([
133+
enabled: bool()
134+
])
130135
uploadedFileInstanceValidation: structure([
131136
enabled: bool()
132137
])
@@ -197,6 +202,8 @@ conditionalTags:
197202
phpstan.rules.rule: %mspirkovYii2Rules.modelRulesValidation.enabled%
198203
MSpirkov\Yii2\PHPStan\Rules\ModelScenariosValidationRule:
199204
phpstan.rules.rule: %mspirkovYii2Rules.modelScenariosValidation.enabled%
205+
MSpirkov\Yii2\PHPStan\Rules\QueryConditionValidationRule:
206+
phpstan.rules.rule: %mspirkovYii2Rules.queryConditionValidation.enabled%
200207
MSpirkov\Yii2\PHPStan\Rules\UploadedFileInstanceValidationRule:
201208
phpstan.rules.rule: %mspirkovYii2Rules.uploadedFileInstanceValidation.enabled%
202209
MSpirkov\Yii2\PHPStan\Rules\WidgetPropertiesValidationRule:
@@ -253,6 +260,8 @@ services:
253260
customValidators: %mspirkovYii2Rules.modelRulesValidation.customValidators%
254261
-
255262
class: MSpirkov\Yii2\PHPStan\Rules\ModelScenariosValidationRule
263+
-
264+
class: MSpirkov\Yii2\PHPStan\Rules\QueryConditionValidationRule
256265
-
257266
class: MSpirkov\Yii2\PHPStan\Rules\UploadedFileInstanceValidationRule
258267
-

src/Analyzers/ExpressionTypeAnalyzer.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,20 @@ public function getSingleClassReflectionOfType(Type $type, string $parentClass):
122122

123123
return array_values($classReflections)[0];
124124
}
125+
126+
/**
127+
* @param list<class-string> $classNames
128+
*/
129+
public function isTypeAnyOf(Type $type, array $classNames): bool
130+
{
131+
foreach ($type->getObjectClassReflections() as $classReflection) {
132+
foreach ($classNames as $className) {
133+
if ($this->isClassReflectionOf($classReflection, $className) || $classReflection->implementsInterface($className)) {
134+
return true;
135+
}
136+
}
137+
}
138+
139+
return false;
140+
}
125141
}

src/Rules/Identifiers.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ final class Identifiers
1818
public const MODEL_ATTRIBUTE_LABELS_VALIDATION = self::PREFIX . 'modelAttributeLabelsValidation';
1919
public const MODEL_RULES_VALIDATION = self::PREFIX . 'modelRulesValidation';
2020
public const MODEL_SCENARIOS_VALIDATION = self::PREFIX . 'modelScenariosValidation';
21+
public const QUERY_CONDITION_VALIDATION = self::PREFIX . 'queryConditionValidation';
2122
public const UPLOADED_FILE_INSTANCE_VALIDATION = self::PREFIX . 'uploadedFileInstanceValidation';
2223
public const WIDGET_PROPERTIES_VALIDATION = self::PREFIX . 'widgetPropertiesValidation';
2324
public const YII_CREATE_OBJECT_VALIDATION = self::PREFIX . 'yiiCreateObjectValidation';
@@ -47,6 +48,7 @@ final class Identifiers
4748
self::MODEL_ATTRIBUTE_LABELS_VALIDATION,
4849
self::MODEL_RULES_VALIDATION,
4950
self::MODEL_SCENARIOS_VALIDATION,
51+
self::QUERY_CONDITION_VALIDATION,
5052
self::UPLOADED_FILE_INSTANCE_VALIDATION,
5153
self::WIDGET_PROPERTIES_VALIDATION,
5254
self::YII_CREATE_OBJECT_VALIDATION,

src/Rules/NoDynamicQueryWhereRule.php

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

55
namespace MSpirkov\Yii2\PHPStan\Rules;
66

7+
use MSpirkov\Yii2\PHPStan\Analyzers\ExpressionTypeAnalyzer;
78
use PhpParser\Node;
89
use PhpParser\Node\Arg;
910
use PhpParser\Node\Expr;
@@ -15,7 +16,6 @@
1516
use PHPStan\Analyser\Scope;
1617
use PHPStan\Rules\IdentifierRuleError;
1718
use PHPStan\Rules\Rule;
18-
use PHPStan\Type\Type;
1919
use yii\db\ActiveQueryInterface;
2020
use yii\db\QueryInterface;
2121

@@ -30,6 +30,13 @@ final class NoDynamicQueryWhereRule implements Rule
3030
QueryInterface::class,
3131
];
3232

33+
private ExpressionTypeAnalyzer $expressionTypeAnalyzer;
34+
35+
public function __construct(ExpressionTypeAnalyzer $expressionTypeAnalyzer)
36+
{
37+
$this->expressionTypeAnalyzer = $expressionTypeAnalyzer;
38+
}
39+
3340
public function getNodeType(): string
3441
{
3542
return MethodCall::class;
@@ -48,7 +55,7 @@ public function processNode(Node $node, Scope $scope): array
4855
return [];
4956
}
5057

51-
if (!$this->isQueryType($scope->getType($node->var))) {
58+
if (!$this->expressionTypeAnalyzer->isTypeAnyOf($scope->getType($node->var), self::QUERY_CLASSES)) {
5259
return [];
5360
}
5461

@@ -81,21 +88,4 @@ private function containsEmbeddedValue(Expr $expr): bool
8188

8289
return !$expr->left instanceof String_ || !$expr->right instanceof String_;
8390
}
84-
85-
private function isQueryType(Type $type): bool
86-
{
87-
foreach ($type->getObjectClassReflections() as $classReflection) {
88-
foreach (self::QUERY_CLASSES as $className) {
89-
if (
90-
$classReflection->is($className)
91-
|| $classReflection->isSubclassOf($className)
92-
|| $classReflection->implementsInterface($className)
93-
) {
94-
return true;
95-
}
96-
}
97-
}
98-
99-
return false;
100-
}
10191
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MSpirkov\Yii2\PHPStan\Rules;
6+
7+
use MSpirkov\Yii2\PHPStan\Analyzers\ExpressionTypeAnalyzer;
8+
use MSpirkov\Yii2\PHPStan\Resolvers\ExpressionValueResolver;
9+
use PhpParser\Node;
10+
use PhpParser\Node\Arg;
11+
use PhpParser\Node\Expr\Array_;
12+
use PhpParser\Node\Expr\MethodCall;
13+
use PhpParser\Node\Identifier;
14+
use PHPStan\Analyser\Scope;
15+
use PHPStan\Rules\IdentifierRuleError;
16+
use PHPStan\Rules\Rule;
17+
use yii\db\ActiveQueryInterface;
18+
use yii\db\QueryInterface;
19+
20+
/**
21+
* @implements Rule<MethodCall>
22+
*/
23+
final class QueryConditionValidationRule implements Rule
24+
{
25+
/** @var list<string> */
26+
private const METHODS = ['where', 'andwhere', 'orwhere'];
27+
28+
/** @var list<class-string> */
29+
private const QUERY_CLASSES = [
30+
ActiveQueryInterface::class,
31+
QueryInterface::class,
32+
];
33+
34+
/** @var list<string> */
35+
private const CONJUNCTION_OPERATORS = ['AND', 'OR', 'NOT'];
36+
37+
/** @var array<string, array{exact: int}|array{atLeast: int}> */
38+
private const OPERAND_REQUIREMENTS = [
39+
'AND' => ['atLeast' => 1],
40+
'OR' => ['atLeast' => 1],
41+
'NOT' => ['exact' => 1],
42+
'BETWEEN' => ['atLeast' => 3],
43+
'NOT BETWEEN' => ['atLeast' => 3],
44+
'IN' => ['atLeast' => 2],
45+
'NOT IN' => ['atLeast' => 2],
46+
'LIKE' => ['atLeast' => 2],
47+
'NOT LIKE' => ['atLeast' => 2],
48+
'OR LIKE' => ['atLeast' => 2],
49+
'OR NOT LIKE' => ['atLeast' => 2],
50+
'EXISTS' => ['atLeast' => 1],
51+
'NOT EXISTS' => ['atLeast' => 1],
52+
'=' => ['exact' => 2],
53+
'!=' => ['exact' => 2],
54+
'<>' => ['exact' => 2],
55+
'>' => ['exact' => 2],
56+
'>=' => ['exact' => 2],
57+
'<' => ['exact' => 2],
58+
'<=' => ['exact' => 2],
59+
];
60+
61+
private ExpressionTypeAnalyzer $expressionTypeAnalyzer;
62+
63+
private ExpressionValueResolver $expressionValueResolver;
64+
65+
public function __construct(
66+
ExpressionTypeAnalyzer $expressionTypeAnalyzer,
67+
ExpressionValueResolver $expressionValueResolver
68+
) {
69+
$this->expressionTypeAnalyzer = $expressionTypeAnalyzer;
70+
$this->expressionValueResolver = $expressionValueResolver;
71+
}
72+
73+
public function getNodeType(): string
74+
{
75+
return MethodCall::class;
76+
}
77+
78+
/**
79+
* @return list<IdentifierRuleError>
80+
*/
81+
public function processNode(Node $node, Scope $scope): array
82+
{
83+
if (!$node->name instanceof Identifier || !in_array(strtolower($node->name->name), self::METHODS, true)) {
84+
return [];
85+
}
86+
87+
if (!$this->expressionTypeAnalyzer->isTypeAnyOf($scope->getType($node->var), self::QUERY_CLASSES)) {
88+
return [];
89+
}
90+
91+
if (!isset($node->args[0]) || !$node->args[0] instanceof Arg) {
92+
return [];
93+
}
94+
95+
if (!$node->args[0]->value instanceof Array_) {
96+
return [];
97+
}
98+
99+
return $this->validateConditionArray($node->args[0]->value, $node->name->name, $scope);
100+
}
101+
102+
/**
103+
* @return list<IdentifierRuleError>
104+
*/
105+
private function validateConditionArray(Array_ $array, string $methodName, Scope $scope): array
106+
{
107+
if (!isset($array->items[0]) || $array->items[0]->unpack || $array->items[0]->key !== null) {
108+
return [];
109+
}
110+
111+
foreach ($array->items as $item) {
112+
if ($item->unpack) {
113+
return [];
114+
}
115+
}
116+
117+
$operatorName = $this->expressionValueResolver->getSingleStringValue($array->items[0]->value, $scope);
118+
if ($operatorName === null) {
119+
return [];
120+
}
121+
122+
$operator = strtoupper($operatorName);
123+
$requirement = self::OPERAND_REQUIREMENTS[$operator] ?? null;
124+
if ($requirement === null) {
125+
return [];
126+
}
127+
128+
$operandItems = array_slice($array->items, 1);
129+
$errors = $this->validateOperandCount(
130+
$operator,
131+
$requirement,
132+
count($operandItems),
133+
$methodName,
134+
$array
135+
);
136+
137+
if (!in_array($operator, self::CONJUNCTION_OPERATORS, true)) {
138+
return $errors;
139+
}
140+
141+
foreach ($operandItems as $operandItem) {
142+
if ($operandItem->value instanceof Array_) {
143+
$errors = array_merge($errors, $this->validateConditionArray(
144+
$operandItem->value,
145+
$methodName,
146+
$scope
147+
));
148+
}
149+
}
150+
151+
return $errors;
152+
}
153+
154+
/**
155+
* @param array{exact: int}|array{atLeast: int} $requirement
156+
*
157+
* @return list<IdentifierRuleError>
158+
*/
159+
private function validateOperandCount(
160+
string $operator,
161+
array $requirement,
162+
int $actualCount,
163+
string $methodName,
164+
Array_ $array
165+
): array {
166+
if (isset($requirement['exact']) && $actualCount !== $requirement['exact']) {
167+
return [$this->buildError($operator, $methodName, 'exactly', $requirement['exact'], $actualCount, $array)];
168+
}
169+
170+
if (isset($requirement['atLeast']) && $actualCount < $requirement['atLeast']) {
171+
return [$this->buildError($operator, $methodName, 'at least', $requirement['atLeast'], $actualCount, $array)];
172+
}
173+
174+
return [];
175+
}
176+
177+
private function buildError(
178+
string $operator,
179+
string $methodName,
180+
string $quantifier,
181+
int $expectedCount,
182+
int $actualCount,
183+
Array_ $array
184+
): IdentifierRuleError {
185+
return ErrorBuilder::build(
186+
sprintf(
187+
'Operator \'%s\' in %s() requires %s %d operand%s, %d given.',
188+
$operator,
189+
$methodName,
190+
$quantifier,
191+
$expectedCount,
192+
$expectedCount === 1 ? '' : 's',
193+
$actualCount
194+
),
195+
Identifiers::QUERY_CONDITION_VALIDATION,
196+
$array->getStartLine()
197+
);
198+
}
199+
}

0 commit comments

Comments
 (0)