diff --git a/README.md b/README.md index 6f0a21e..9f88fbd 100644 --- a/README.md +++ b/README.md @@ -166,3 +166,22 @@ To install dev-dependencies use `composer setup`. Then you will be able to run `composer test` and `composer cs-check` etc. See also `Makefile` for more shortcut commands. + +### CakePHP 6 condition predicates + +The `cakephp60` ruleset replaces operator-bearing query condition keys with +namespaced predicates, for example `'id >' => $minimum` becomes +`'id' => greaterThan($minimum)` with a grouped `use function` declaration. Plain equality and boolean +arrays remain supported, as does the fluent expression builder. + +Conversions are limited to known CakePHP condition consumers. Inline conditions, +callback return arrays, and local arrays whose reads are exclusively condition +arguments can be converted. Repeated fields are kept in separate nested arrays. +Nullable `IS` and `IS NOT` use `equals()` and `notEquals()`, preserving their runtime choice +between a null check and an ordinary comparison. Custom recognized operators use +`comparison()`, whose operator argument remains trusted SQL. + +Review dynamically constructed keys, shared condition/configuration arrays, +arrays assembled incrementally, and custom condition consumers manually. The rule +leaves ambiguous arrays unchanged rather than changing unrelated application data. +Run the tool before upgrading application dependencies to CakePHP 6. diff --git a/config/rector/sets/cakephp60.php b/config/rector/sets/cakephp60.php index 1440602..11a38ee 100644 --- a/config/rector/sets/cakephp60.php +++ b/config/rector/sets/cakephp60.php @@ -3,6 +3,7 @@ use Cake\Upgrade\Rector\Cake6\BinaryColumnToVarbinaryRector; use Cake\Upgrade\Rector\Cake6\BreadcrumbsHelperTitleToContentRector; +use Cake\Upgrade\Rector\Cake6\ConditionOperatorsToPredicatesRector; use Cake\Upgrade\Rector\Cake6\EventManagerOnRector; use Cake\Upgrade\Rector\Cake6\RemoveAssignmentFromVoidMethodRector; use Cake\Upgrade\Rector\Cake6\ReplaceCommandArgsIoWithPropertiesRector; @@ -25,6 +26,8 @@ # @see https://book.cakephp.org/6/en/appendices/6-0-migration-guide.html return static function (RectorConfig $rectorConfig): void { + $rectorConfig->rule(ConditionOperatorsToPredicatesRector::class); + // EventManager::on() signature change $rectorConfig->rule(EventManagerOnRector::class); diff --git a/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php new file mode 100644 index 0000000..4ed88a7 --- /dev/null +++ b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php @@ -0,0 +1,523 @@ + + */ + private const HELPERS = [ + '=' => 'equals', '!=' => 'notEquals', '<>' => 'notEquals', + '>' => 'greaterThan', '>=' => 'greaterThanOrEquals', '<' => 'lessThan', '<=' => 'lessThanOrEquals', + 'IN' => 'in', 'NOT IN' => 'notIn', 'LIKE' => 'like', 'NOT LIKE' => 'notLike', + 'IS' => 'equals', 'IS NOT' => 'notEquals', + 'IS DISTINCT FROM' => 'isDistinctFrom', 'IS NOT DISTINCT FROM' => 'isNotDistinctFrom', + ]; + + /** + * Creates the AST finder used to inspect local variable references. + * + * @param \PhpParser\NodeFinder $finder The node finder. + */ + public function __construct(private readonly NodeFinder $finder = new NodeFinder()) + { + } + + /** + * Describes the condition migration and its generated code. + * + * @return \Symplify\RuleDocGenerator\ValueObject\RuleDefinition + */ + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition('Replace condition operators with predicate functions', [ + new CodeSample( + '$query->where([\'id >\' => $minimum]);', + <<<'CODE' +use function Cake\Database\Expression\greaterThan; + +$query->where(['id' => greaterThan($minimum)]); +CODE, + ), + ]); + } + + /** + * Lists nodes containing typed condition consumers or local condition variables. + * + * @return array> + */ + public function getNodeTypes(): array + { + return [FileNode::class]; + } + + /** + * Converts only arrays proven to be used as CakePHP conditions. + * + * @param \PhpParser\Node $node The node to migrate. + * @return \PhpParser\Node|null + */ + public function refactor(Node $node): ?Node + { + if (!$node instanceof FileNode) { + return null; + } + $this->currentFile = $node; + $changed = false; + foreach ($this->finder->find($node->stmts, static fn(Node $n): bool => $n instanceof ClassMethod || $n instanceof Function_) as $scope) { + if ($scope instanceof ClassMethod || $scope instanceof Function_) { + $changed = $this->convertScope($scope) || $changed; + } + } + $references = []; + foreach ($this->finder->find($node->stmts, static fn(Node $n): bool => $n instanceof MethodCall || $n instanceof New_) as $call) { + $changed = $this->convertCall($call, $references) || $changed; + } + + return $changed ? $node : null; + } + + /** + * Converts inline conditions and local arrays used exclusively as condition arguments. + * + * @param \PhpParser\Node\Stmt\ClassMethod|\PhpParser\Node\Stmt\Function_ $node The local scope. + * @return bool Whether code changed. + */ + private function convertScope(ClassMethod|Function_ $node): bool + { + $references = []; + $changed = false; + foreach ($this->finder->find($node->stmts ?? [], static fn(Node $n): bool => $n instanceof MethodCall || $n instanceof New_) as $call) { + $changed = $this->convertCall($call, $references) || $changed; + } + foreach ($references as $name => $uses) { + $variables = $this->finder->find($node->stmts ?? [], static fn(Node $n): bool => $n instanceof Variable && $n->name === $name); + $assignments = $this->finder->find($node->stmts ?? [], static fn(Node $n): bool => $n instanceof Assign && $n->var instanceof Variable && $n->var->name === $name); + if (count($assignments) !== 1 || count($variables) !== count($uses) + 1) { + continue; + } + $assignment = $assignments[0]; + if ($assignment instanceof Assign && $assignment->expr instanceof Array_) { + $changed = $this->convertConditions($assignment->expr) || $changed; + } + } + + return $changed; + } + + /** + * Finds condition arguments on supported CakePHP objects. + * + * @param \PhpParser\Node $node The call or construction to examine. + * @param array> $references Proven condition variable reads. + * @return bool Whether code changed. + */ + private function convertCall(Node $node, array &$references): bool + { + if ($node instanceof New_) { + if (!$this->isName($node->class, 'Cake\Database\Expression\QueryExpression')) { + return false; + } + + $changed = false; + foreach ($node->args as $position => $arg) { + if ($arg instanceof Arg && (($arg->name === null && $position === 0) || $arg->name?->toString() === 'conditions')) { + $changed = $this->convertArgument($arg, $references) || $changed; + } + } + + return $changed; + } + if (!$node instanceof MethodCall || !$node->name instanceof Identifier || $node->isFirstClassCallable()) { + return false; + } + + $method = strtolower($node->name->toString()); + $positions = []; + $options = []; + if ($this->isObjectType($node->var, new ObjectType('Cake\Database\Connection'))) { + $positions = match ($method) { + 'update' => [2], + 'delete' => [1], + default => [], + }; + } + if ($this->isObjectType($node->var, new ObjectType('Cake\Database\Query'))) { + if (in_array($method, ['where', 'andwhere', 'having', 'andhaving', 'expr', 'newexpr'], true)) { + $positions = [0]; + } elseif (in_array($method, ['innerjoin', 'leftjoin', 'rightjoin'], true)) { + $positions = [1]; + } elseif ($method === 'join') { + $options = [0]; + } + } + if ( + $this->isObjectType($node->var, new ObjectType('Cake\Database\Expression\QueryExpression')) + && !$this->isObjectType($node->var, new ObjectType('Cake\Database\Expression\FunctionExpression')) + && !$this->isObjectType($node->var, new ObjectType('Cake\Database\Expression\OrderByExpression')) && in_array($method, ['add', 'and', 'or', 'not'], true) + ) { + $positions = [0]; + } + foreach (['CaseStatementExpression', 'WhenThenExpression', 'AggregateExpression'] as $class) { + if ($this->isObjectType($node->var, new ObjectType('Cake\Database\Expression\\' . $class)) && in_array($method, ['when', 'filter'], true)) { + $positions = [0]; + } + } + foreach (['Cake\ORM\Table', 'Cake\ORM\Association'] as $class) { + if (!$this->isObjectType($node->var, new ObjectType($class))) { + continue; + } + if (in_array($method, ['exists', 'deleteall', 'setconditions'], true)) { + $positions = [0]; + } elseif ($method === 'updateall') { + $positions = [1]; + } elseif (in_array($method, ['find', 'findall', 'findlist', 'findthreaded', 'hasmany', 'hasone', 'belongsto', 'belongstomany'], true)) { + $options = [1]; + } + } + + $changed = false; + foreach ($node->args as $position => $arg) { + if (!$arg instanceof Arg) { + continue; + } + $name = $arg->name?->toString(); + if (($name === null && in_array($position, $positions, true)) || ($positions !== [] && in_array($name, ['conditions', 'rawExpression'], true))) { + $changed = $this->convertArgument($arg, $references) || $changed; + } elseif (($name === null && in_array($position, $options, true)) || ($options !== [] && in_array($name, ['options', 'tables'], true))) { + if ($arg->value instanceof Array_) { + $changed = $this->convertOptions($arg->value, $references) || $changed; + } + } elseif ($options !== [] && $name === 'conditions') { + $changed = $this->convertArgument($arg, $references) || $changed; + } + } + + return $changed; + } + + /** + * Converts conditions inside join or association options without touching other data. + * + * @param \PhpParser\Node\Expr\Array_ $array The options array. + * @param array> $references Proven condition variable reads. + * @return bool Whether code changed. + */ + private function convertOptions(Array_ $array, array &$references): bool + { + $changed = false; + foreach ($array->items as $item) { + if ($item->key instanceof String_ && strtolower($item->key->value) === 'conditions') { + $changed = $this->convertArgument(new Arg($item->value), $references) || $changed; + } elseif ($item->value instanceof Array_) { + $changed = $this->convertOptions($item->value, $references) || $changed; + } + } + + return $changed; + } + + /** + * Converts an inline array, callback result, or records a local condition variable. + * + * @param \PhpParser\Node\Arg|null $arg The condition argument. + * @param array> $references Proven condition variable reads. + * @return bool Whether code changed. + */ + private function convertArgument(?Arg $arg, array &$references): bool + { + if ($arg === null) { + return false; + } + $value = $arg->value; + if ($value instanceof Array_) { + return $this->convertConditions($value); + } + if ($value instanceof Variable && is_string($value->name)) { + $references[$value->name][spl_object_id($value)] = true; + } + if ($value instanceof ArrowFunction) { + return $this->convertArgument(new Arg($value->expr), $references); + } + if ($value instanceof Closure) { + $changed = false; + foreach ($value->stmts as $statement) { + if ($statement instanceof Return_ && $statement->expr !== null) { + $changed = $this->convertArgument(new Arg($statement->expr), $references) || $changed; + } + } + + return $changed; + } + + return false; + } + + /** + * Converts condition keys while preserving boolean grouping and field collisions. + * + * @param \PhpParser\Node\Expr\Array_ $array The condition array. + * @return bool Whether code changed. + */ + private function convertConditions(Array_ $array): bool + { + $changed = false; + $seen = []; + foreach ($array->items as $item) { + if (($item->unpack || ($item->key !== null && !$item->key instanceof String_ && !$item->key instanceof Int_))) { + return false; + } + if ($item->key instanceof String_) { + if (isset($seen[$item->key->value])) { + return false; + } + $seen[$item->key->value] = true; + } + } + $fields = []; + $parsed = []; + foreach ($array->items as $index => $item) { + if (($item->key === null || $item->key instanceof Int_ || ($item->key instanceof String_ && in_array(strtolower($item->key->value), ['and', 'or', 'xor', 'not'], true))) && $item->value instanceof Array_) { + $changed = $this->convertConditions($item->value) || $changed; + continue; + } + if (!$item->key instanceof String_) { + continue; + } + $parts = $this->parseKey($item->key->value); + $field = $parts[0] ?? $item->key->value; + $fields[$field] = ($fields[$field] ?? 0) + 1; + if ($parts !== null) { + $parsed[$index] = $parts; + } + } + foreach ($parsed as $index => [$field, $operator]) { + $item = $array->items[$index]; + $helper = self::HELPERS[$operator] ?? 'comparison'; + $args = [new Arg($item->value)]; + if ($helper === 'comparison') { + array_unshift($args, new Arg(new String_($operator))); + } + $item->value = new FuncCall($this->predicateFunction($helper, $array), $args); + $item->key = new String_($field); + if ($fields[$field] > 1) { + $array->items[$index] = new ArrayItem(new Array_([$item]), null); + } + $changed = true; + } + + return $changed; + } + + /** + * Imports a predicate function without changing existing function resolution. + * + * @param string $helper The predicate function to import. + * @param \PhpParser\Node\Expr\Array_ $array The condition array's namespace context. + * @return \PhpParser\Node\Name The imported function name or alias. + */ + private function predicateFunction(string $helper, Array_ $array): Name + { + $fileNode = $this->currentFile; + $namespace = ScopeFetcher::fetch($array)->getNamespace(); + $statements = &$fileNode->stmts; + foreach ($fileNode->stmts as $statement) { + if ($statement instanceof Namespace_ && $statement->name?->toString() === $namespace) { + $statements = &$statement->stmts; + break; + } + } + + $target = 'Cake\Database\Expression\\' . $helper; + $occupied = []; + foreach ($statements as $statement) { + if (!$statement instanceof Use_ && !$statement instanceof GroupUse) { + continue; + } + foreach ($statement->uses as $use) { + if (($use->type ?: $statement->type) !== Use_::TYPE_FUNCTION) { + continue; + } + $name = ($statement instanceof GroupUse ? $statement->prefix->toString() . '\\' : '') + . $use->name->toString(); + if (strcasecmp($name, $target) === 0) { + return new Name($use->getAlias()->toString()); + } + $occupied[strtolower($use->getAlias()->toString())] = true; + } + } + foreach ($this->finder->findInstanceOf($statements, Function_::class) as $function) { + $occupied[strtolower($function->name->toString())] = true; + } + foreach ($this->finder->findInstanceOf($statements, FuncCall::class) as $call) { + if ($call->name instanceof Name && $call->name->isUnqualified()) { + $occupied[strtolower($call->name->toString())] = true; + } + } + + $alias = $helper; + if (isset($occupied[strtolower($alias)])) { + $alias = 'cake' . ucfirst($helper); + for ($suffix = 2; isset($occupied[strtolower($alias)]); $suffix++) { + $alias = 'cake' . ucfirst($helper) . $suffix; + } + } + $this->addPredicateImport($statements, $helper, $alias); + + return new Name($alias); + } + + /** + * Groups predicate imports while keeping generated declarations within the coding standard's line length. + * + * @param array<\PhpParser\Node\Stmt> $statements The namespace statements. + * @param string $helper The predicate function to import. + * @param string $alias The collision-free local name. + * @return void + */ + private function addPredicateImport(array &$statements, string $helper, string $alias): void + { + $prefix = 'Cake\Database\Expression'; + $members = [new UseItem(new Name($helper), $alias === $helper ? null : new Identifier($alias))]; + $remaining = []; + $comments = []; + foreach ($statements as $statement) { + if ( + $statement instanceof GroupUse && $statement->type === Use_::TYPE_FUNCTION + && $statement->prefix->toString() === $prefix + ) { + $members = [...$members, ...$statement->uses]; + $comments = [...$comments, ...$statement->getComments()]; + } elseif ( + $statement instanceof Use_ && $statement->type === Use_::TYPE_FUNCTION + && count($statement->uses) === 1 + && str_starts_with($statement->uses[0]->name->toString(), $prefix . '\\') + && !str_contains(substr($statement->uses[0]->name->toString(), strlen($prefix) + 1), '\\') + ) { + $use = $statement->uses[0]; + $members[] = new UseItem(new Name($use->name->getLast()), $use->alias); + $comments = [...$comments, ...$statement->getComments()]; + } else { + $remaining[] = $statement; + } + } + usort($members, static fn(UseItem $a, UseItem $b): int => strcasecmp($a->name->toString(), $b->name->toString())); + $chunks = []; + $chunk = []; + $length = strlen('use function ' . $prefix . '\{};'); + foreach ($members as $member) { + $memberLength = strlen($member->name->toString()) + + ($member->alias === null ? 0 : strlen(' as ' . $member->alias->toString())) + 2; + if ($chunk !== [] && $length + $memberLength > 120) { + $chunks[] = $chunk; + $chunk = []; + $length = strlen('use function ' . $prefix . '\{};'); + } + $chunk[] = $member; + $length += $memberLength; + } + $chunks[] = $chunk; + $imports = []; + foreach ($chunks as $chunk) { + $imports[] = count($chunk) === 1 + ? new Use_([new UseItem(new Name($prefix . '\\' . $chunk[0]->name->toString()), $chunk[0]->alias)], Use_::TYPE_FUNCTION) + : new GroupUse(new Name($prefix), $chunk, Use_::TYPE_FUNCTION); + } + $imports[0]->setAttribute('comments', $comments); + $position = 0; + foreach ($remaining as $index => $statement) { + if ($statement instanceof Declare_ || $statement instanceof Use_ || $statement instanceof GroupUse) { + $position = $index + 1; + } + if ( + ($statement instanceof Use_ && $statement->type === Use_::TYPE_FUNCTION + && strcasecmp($statement->uses[0]->name->toString(), $prefix . '\\') > 0) + || ($statement instanceof GroupUse && $statement->type === Use_::TYPE_FUNCTION + && strcasecmp($statement->prefix->toString(), $prefix) > 0) + ) { + $position = $index; + break; + } + } + array_splice($remaining, $position, 0, $imports); + $statements = $remaining; + } + + /** + * Splits a static legacy key using the original parser's multi-word operator rules. + * + * @param string $key The legacy condition key. + * @return array{string, string}|null The field and operator, when present. + */ + private function parseKey(string $key): ?array + { + $key = trim($key); + $isOperators = 'IS(?:\\s+NOT)?(?:\\s+DISTINCT\\s+FROM)?'; + $wordOperators = '(?:NOT\\s+)?SIMILAR\\s+TO|NOT\\s+\\w+'; + $operatorPattern = '/^(.*?)\\s+(' . $isOperators . '|' . $wordOperators . '|[^\\s]+)$/i'; + if (!preg_match($operatorPattern, $key, $matches)) { + return null; + } + $field = trim($matches[1]); + $operator = strtoupper((string)preg_replace('/\\s+/', ' ', $matches[2])); + if ($field === '' || str_ends_with($key, ')') || str_ends_with($key, "'") || str_ends_with($key, '"')) { + return null; + } + + if ( + !isset(self::HELPERS[$operator]) + && !preg_match('/^[!<>=~@#%^&|?+*\\\\\\/-]+$/', $operator) + && !in_array($operator, ['ILIKE', 'NOT ILIKE', 'REGEXP', 'NOT REGEXP', 'RLIKE', 'NOT RLIKE', 'GLOB', 'MATCH', 'SIMILAR TO', 'NOT SIMILAR TO', 'OVERLAPS'], true) + ) { + return null; + } + + return [$field, $operator]; + } +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/ConditionOperatorsToPredicatesRectorTest.php b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/ConditionOperatorsToPredicatesRectorTest.php new file mode 100644 index 0000000..0f6be6e --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/ConditionOperatorsToPredicatesRectorTest.php @@ -0,0 +1,45 @@ +doTestFile($filePath); + } + + /** + * Provides migration fixtures. + * + * @return \Iterator + */ + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + /** + * Returns the isolated predicate migration configuration. + * + * @return string + */ + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc new file mode 100644 index 0000000..c1bfce6 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc @@ -0,0 +1,35 @@ +where(['id >' => $value, 'id <' => 100, 'title LIKE' => '%cake%']); + greaterThan($value); +} + +function like() +{ +} +----- +where([['id' => cakeGreaterThan($value)], ['id' => less(100)], 'title' => cakeLike('%cake%')]); + greaterThan($value); +} + +function like() +{ +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/conditions.php.inc b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/conditions.php.inc new file mode 100644 index 0000000..302008e --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/conditions.php.inc @@ -0,0 +1,59 @@ +where(['id >' => $value, 'id <' => 100, 'OR' => ['title LIKE' => '%cake%', 'deleted IS' => $value]]); + $query->having(['COUNT(id) >=' => 2]); + $query->innerJoin('authors', ['authors.active IS NOT' => null]); + $query->join(['a' => ['table' => 'authors', 'conditions' => ['a.id !=' => 0]]]); + $exp->add(['id IN' => [1, 2], 'data @>' => $value]); + $aggregate->filter(['rating >=' => 3]); + new QueryExpression(['id <>' => $value]); + $table->exists(['name IS NOT DISTINCT FROM' => $value]); + $table->find('all', conditions: ['name NOT LIKE' => '%spam%']); + $table->updateAll(['title' => 'title'], ['id NOT IN' => [3, 4]]); + $query->where(fn () => ['id <=' => $value]); + new QueryExpression(types: ['id' => 'integer'], conditions: ['id >' => $value]); + $query->where(types: ['id' => 'integer'], conditions: ['id >' => $value]); + $connection->update('articles', ['title' => 'updated'], ['id >' => $value]); + $connection->delete('articles', ['id <' => $value]); + $conditions = ['id >' => $value]; + $query->where($conditions); +} +----- +where([['id' => greaterThan($value)], ['id' => lessThan(100)], 'OR' => ['title' => like('%cake%'), 'deleted' => equals($value)]]); + $query->having(['COUNT(id)' => greaterThanOrEquals(2)]); + $query->innerJoin('authors', ['authors.active' => notEquals(null)]); + $query->join(['a' => ['table' => 'authors', 'conditions' => ['a.id' => notEquals(0)]]]); + $exp->add(['id' => in([1, 2]), 'data' => comparison('@>', $value)]); + $aggregate->filter(['rating' => greaterThanOrEquals(3)]); + new QueryExpression(['id' => notEquals($value)]); + $table->exists(['name' => isNotDistinctFrom($value)]); + $table->find('all', conditions: ['name' => notLike('%spam%')]); + $table->updateAll(['title' => 'title'], ['id' => notIn([3, 4])]); + $query->where(fn () => ['id' => lessThanOrEquals($value)]); + new QueryExpression(types: ['id' => 'integer'], conditions: ['id' => greaterThan($value)]); + $query->where(types: ['id' => 'integer'], conditions: ['id' => greaterThan($value)]); + $connection->update('articles', ['title' => 'updated'], ['id' => greaterThan($value)]); + $connection->delete('articles', ['id' => lessThan($value)]); + $conditions = ['id' => greaterThan($value)]; + $query->where($conditions); +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc new file mode 100644 index 0000000..d1a47f6 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc @@ -0,0 +1,22 @@ +where(['id >' => $minimum]); +} +----- +where(['id' => greaterThan($minimum)]); +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/skip_ambiguous.php.inc b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/skip_ambiguous.php.inc new file mode 100644 index 0000000..e18d2b8 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/skip_ambiguous.php.inc @@ -0,0 +1,19 @@ +add(['DOW FROM' => 'literal']); + $function->add(['RANDOM() % 1' => 'literal']); + $query->where(['Column with spaces' => $value]); + $other->where(['id >' => $value]); + $data = ['id >' => $value]; + echo json_encode($data); + $query->where($data); + $query->where([$key => $value]); + $query->where(['id >' => $value, $key => $value]); + $query->where(['id >' => $value, ...$data]); + $query->where(['data' => ['id >' => $value]]); + $query->where(['id' => \Cake\Database\Expression\greaterThan($value)]); +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/whitespace.php.inc b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/whitespace.php.inc new file mode 100644 index 0000000..3efd476 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/whitespace.php.inc @@ -0,0 +1,45 @@ +where([ + ' id > ' => 1, + 'name NOT LIKE ' => '%cake%', + 'parent_id IS NOT' => null, + 'category_id IS DISTINCT FROM' => $value, + "tenant_id IS \t NOT \n DISTINCT FROM " => $value, + "status\tNOT\tIN\t" => ['archived'], + 'label NOT ILIKE ' => '%Cake%', + 'content NOT SIMILAR TO ' => 'cake', + 'updated IS ' => null, + "COALESCE(title, 'two spaces') NOT LIKE " => '%cake%', + ]); +} +----- +where([ + 'id' => greaterThan(1), + 'name' => notLike('%cake%'), + 'parent_id' => notEquals(null), + 'category_id' => isDistinctFrom($value), + 'tenant_id' => isNotDistinctFrom($value), + 'status' => notIn(['archived']), + 'label' => comparison('NOT ILIKE', '%Cake%'), + 'content' => comparison('NOT SIMILAR TO', 'cake'), + 'updated' => equals(null), + 'COALESCE(title, \'two spaces\')' => notLike('%cake%'), + ]); +} diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/config/configured_rule.php b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/config/configured_rule.php new file mode 100644 index 0000000..71d9657 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/config/configured_rule.php @@ -0,0 +1,9 @@ +rule(ConditionOperatorsToPredicatesRector::class); +}; diff --git a/tests/test_apps/original/RectorCommand-testApply60/src/Conditions.php b/tests/test_apps/original/RectorCommand-testApply60/src/Conditions.php new file mode 100644 index 0000000..1116802 --- /dev/null +++ b/tests/test_apps/original/RectorCommand-testApply60/src/Conditions.php @@ -0,0 +1,13 @@ +where([ + 'id >' => $minimum, + 'id <' => 100, + 'deleted IS' => $deleted, + 'metadata @>' => '{"active":true}', + ]); +} diff --git a/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php b/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php new file mode 100644 index 0000000..b63e003 --- /dev/null +++ b/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php @@ -0,0 +1,14 @@ +where([ + ['id' => greaterThan($minimum)], + ['id' => lessThan(100)], + 'deleted' => equals($deleted), + 'metadata' => comparison('@>', '{"active":true}'), + ]); +}