From 96623a909a2a67698587ebb90f42b579db006962 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 24 Oct 2026 09:30:57 +0200 Subject: [PATCH 1/3] Convert condition operators to predicate functions for CakePHP 6 --- README.md | 19 + config/rector/sets/cakephp60.php | 3 + .../ConditionOperatorsToPredicatesRector.php | 523 ++++++++++++++++++ ...nditionOperatorsToPredicatesRectorTest.php | 45 ++ .../Fixture/aliases.php.inc | 35 ++ .../Fixture/conditions.php.inc | 59 ++ .../Fixture/single.php.inc | 22 + .../Fixture/skip_ambiguous.php.inc | 19 + .../config/configured_rule.php | 9 + .../src/Conditions.php | 13 + .../src/Conditions.php | 14 + 11 files changed, 761 insertions(+) create mode 100644 src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/ConditionOperatorsToPredicatesRectorTest.php create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/conditions.php.inc create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/skip_ambiguous.php.inc create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/config/configured_rule.php create mode 100644 tests/test_apps/original/RectorCommand-testApply60/src/Conditions.php create mode 100644 tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php diff --git a/README.md b/README.md index 6f0a21e..752eeed 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' => gt($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 `is()` and `isNot()`, 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..02257d7 --- /dev/null +++ b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php @@ -0,0 +1,523 @@ + + */ + private const HELPERS = [ + '=' => 'eq', '!=' => 'notEq', '<>' => 'notEq', + '>' => 'gt', '>=' => 'gte', '<' => 'lt', '<=' => 'lte', + 'IN' => 'in', 'NOT IN' => 'notIn', 'LIKE' => 'like', 'NOT LIKE' => 'notLike', + 'IS' => 'is', 'IS NOT' => 'isNot', + '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\gt; + +$query->where(['id' => gt($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); + if (!str_contains($key, ' ')) { + return null; + } + if (!preg_match('/^(.*?)\\s+(IS NOT DISTINCT FROM|IS DISTINCT FROM|IS NOT|(?:NOT )?SIMILAR TO|NOT \\w+|[^\\s]+)$/i', $key, $matches)) { + return null; + } + $field = trim($matches[1]); + $operator = strtoupper(trim($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..5337318 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc @@ -0,0 +1,35 @@ +where(['id >' => $value, 'id <' => 100, 'title LIKE' => '%cake%']); + gt($value); +} + +function like() +{ +} +----- +where([['id' => cakeGt($value)], ['id' => less(100)], 'title' => cakeLike('%cake%')]); + gt($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..539df59 --- /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' => gt($value)], ['id' => lt(100)], 'OR' => ['title' => like('%cake%'), 'deleted' => is($value)]]); + $query->having(['COUNT(id)' => gte(2)]); + $query->innerJoin('authors', ['authors.active' => isNot(null)]); + $query->join(['a' => ['table' => 'authors', 'conditions' => ['a.id' => notEq(0)]]]); + $exp->add(['id' => in([1, 2]), 'data' => comparison('@>', $value)]); + $aggregate->filter(['rating' => gte(3)]); + new QueryExpression(['id' => notEq($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' => lte($value)]); + new QueryExpression(types: ['id' => 'integer'], conditions: ['id' => gt($value)]); + $query->where(types: ['id' => 'integer'], conditions: ['id' => gt($value)]); + $connection->update('articles', ['title' => 'updated'], ['id' => gt($value)]); + $connection->delete('articles', ['id' => lt($value)]); + $conditions = ['id' => gt($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..9737a86 --- /dev/null +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc @@ -0,0 +1,22 @@ +where(['id >' => $minimum]); +} +----- +where(['id' => gt($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..2f1686c --- /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\gt($value)]); +} 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..6de1a93 --- /dev/null +++ b/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php @@ -0,0 +1,14 @@ +where([ + ['id' => gt($minimum)], + ['id' => lt(100)], + 'deleted' => is($deleted), + 'metadata' => comparison('@>', '{"active":true}'), + ]); +} From 600f16d03b828b955b1a48173219449a65e7fd70 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 24 Oct 2026 10:01:48 +0200 Subject: [PATCH 2/3] Update predicate migration for Chronos comparison names --- README.md | 4 +-- .../ConditionOperatorsToPredicatesRector.php | 10 +++---- .../Fixture/aliases.php.inc | 14 +++++----- .../Fixture/conditions.php.inc | 28 +++++++++---------- .../Fixture/single.php.inc | 4 +-- .../Fixture/skip_ambiguous.php.inc | 2 +- .../src/Conditions.php | 8 +++--- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 752eeed..9f88fbd 100644 --- a/README.md +++ b/README.md @@ -171,13 +171,13 @@ See also `Makefile` for more shortcut commands. The `cakephp60` ruleset replaces operator-bearing query condition keys with namespaced predicates, for example `'id >' => $minimum` becomes -`'id' => gt($minimum)` with a grouped `use function` declaration. Plain equality and boolean +`'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 `is()` and `isNot()`, preserving their runtime choice +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. diff --git a/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php index 02257d7..5770bcd 100644 --- a/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php +++ b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php @@ -52,10 +52,10 @@ final class ConditionOperatorsToPredicatesRector extends AbstractRector * @var array */ private const HELPERS = [ - '=' => 'eq', '!=' => 'notEq', '<>' => 'notEq', - '>' => 'gt', '>=' => 'gte', '<' => 'lt', '<=' => 'lte', + '=' => 'equals', '!=' => 'notEquals', '<>' => 'notEquals', + '>' => 'greaterThan', '>=' => 'greaterThanOrEquals', '<' => 'lessThan', '<=' => 'lessThanOrEquals', 'IN' => 'in', 'NOT IN' => 'notIn', 'LIKE' => 'like', 'NOT LIKE' => 'notLike', - 'IS' => 'is', 'IS NOT' => 'isNot', + 'IS' => 'equals', 'IS NOT' => 'notEquals', 'IS DISTINCT FROM' => 'isDistinctFrom', 'IS NOT DISTINCT FROM' => 'isNotDistinctFrom', ]; @@ -79,9 +79,9 @@ public function getRuleDefinition(): RuleDefinition new CodeSample( '$query->where([\'id >\' => $minimum]);', <<<'CODE' -use function Cake\Database\Expression\gt; +use function Cake\Database\Expression\greaterThan; -$query->where(['id' => gt($minimum)]); +$query->where(['id' => greaterThan($minimum)]); CODE, ), ]); diff --git a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc index 5337318..c1bfce6 100644 --- a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/aliases.php.inc @@ -3,13 +3,13 @@ namespace App; use Cake\Database\Query\SelectQuery; -use function App\Filters\gt; -use function Cake\Database\Expression\lt as less; +use function App\Filters\greaterThan; +use function Cake\Database\Expression\lessThan as less; function filterArticles(SelectQuery $query, $value) { $query->where(['id >' => $value, 'id <' => 100, 'title LIKE' => '%cake%']); - gt($value); + greaterThan($value); } function like() @@ -21,13 +21,13 @@ function like() namespace App; use Cake\Database\Query\SelectQuery; -use function App\Filters\gt; -use function Cake\Database\Expression\{gt as cakeGt, like as cakeLike, lt as less}; +use function App\Filters\greaterThan; +use function Cake\Database\Expression\{greaterThan as cakeGreaterThan, lessThan as less, like as cakeLike}; function filterArticles(SelectQuery $query, $value) { - $query->where([['id' => cakeGt($value)], ['id' => less(100)], 'title' => cakeLike('%cake%')]); - gt($value); + $query->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 index 539df59..302008e 100644 --- a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/conditions.php.inc +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/conditions.php.inc @@ -34,26 +34,26 @@ use Cake\Database\Expression\AggregateExpression; use Cake\Database\Expression\QueryExpression; use Cake\Database\Query\SelectQuery; use Cake\ORM\Table; -use function Cake\Database\Expression\{comparison, gt, gte, in, is, isNot, isNotDistinctFrom, like, lt, lte, notEq}; -use function Cake\Database\Expression\{notIn, notLike}; +use function Cake\Database\Expression\{comparison, equals, greaterThan, greaterThanOrEquals, in, isNotDistinctFrom}; +use function Cake\Database\Expression\{lessThan, lessThanOrEquals, like, notEquals, notIn, notLike}; function conditions(SelectQuery $query, QueryExpression $exp, AggregateExpression $aggregate, Table $table, Connection $connection, $value) { - $query->where([['id' => gt($value)], ['id' => lt(100)], 'OR' => ['title' => like('%cake%'), 'deleted' => is($value)]]); - $query->having(['COUNT(id)' => gte(2)]); - $query->innerJoin('authors', ['authors.active' => isNot(null)]); - $query->join(['a' => ['table' => 'authors', 'conditions' => ['a.id' => notEq(0)]]]); + $query->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' => gte(3)]); - new QueryExpression(['id' => notEq($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' => lte($value)]); - new QueryExpression(types: ['id' => 'integer'], conditions: ['id' => gt($value)]); - $query->where(types: ['id' => 'integer'], conditions: ['id' => gt($value)]); - $connection->update('articles', ['title' => 'updated'], ['id' => gt($value)]); - $connection->delete('articles', ['id' => lt($value)]); - $conditions = ['id' => gt($value)]; + $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 index 9737a86..d1a47f6 100644 --- a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/single.php.inc @@ -14,9 +14,9 @@ function filterOne(SelectQuery $query, $minimum) namespace App; use Cake\Database\Query\SelectQuery; -use function Cake\Database\Expression\gt; +use function Cake\Database\Expression\greaterThan; function filterOne(SelectQuery $query, $minimum) { - return $query->where(['id' => gt($minimum)]); + return $query->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 index 2f1686c..e18d2b8 100644 --- a/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/skip_ambiguous.php.inc +++ b/tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/skip_ambiguous.php.inc @@ -15,5 +15,5 @@ function unchanged(SelectQuery $query, \Cake\Database\Expression\FunctionExpress $query->where(['id >' => $value, $key => $value]); $query->where(['id >' => $value, ...$data]); $query->where(['data' => ['id >' => $value]]); - $query->where(['id' => \Cake\Database\Expression\gt($value)]); + $query->where(['id' => \Cake\Database\Expression\greaterThan($value)]); } diff --git a/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php b/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php index 6de1a93..b63e003 100644 --- a/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php +++ b/tests/test_apps/upgraded/RectorCommand-testApply60/src/Conditions.php @@ -1,14 +1,14 @@ where([ - ['id' => gt($minimum)], - ['id' => lt(100)], - 'deleted' => is($deleted), + ['id' => greaterThan($minimum)], + ['id' => lessThan(100)], + 'deleted' => equals($deleted), 'metadata' => comparison('@>', '{"active":true}'), ]); } From 7a0d340a7dbd145dea0b30eb3c2dc87e27cd0540 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 24 Oct 2026 13:33:41 +0200 Subject: [PATCH 3/3] Handle whitespace inside legacy condition operators --- .../ConditionOperatorsToPredicatesRector.php | 10 ++--- .../Fixture/whitespace.php.inc | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 tests/TestCase/Rector/MethodCall/ConditionOperatorsToPredicatesRector/Fixture/whitespace.php.inc diff --git a/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php index 5770bcd..4ed88a7 100644 --- a/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php +++ b/src/Rector/Cake6/ConditionOperatorsToPredicatesRector.php @@ -498,14 +498,14 @@ private function addPredicateImport(array &$statements, string $helper, string $ private function parseKey(string $key): ?array { $key = trim($key); - if (!str_contains($key, ' ')) { - return null; - } - if (!preg_match('/^(.*?)\\s+(IS NOT DISTINCT FROM|IS DISTINCT FROM|IS NOT|(?:NOT )?SIMILAR TO|NOT \\w+|[^\\s]+)$/i', $key, $matches)) { + $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(trim($matches[2])); + $operator = strtoupper((string)preg_replace('/\\s+/', ' ', $matches[2])); if ($field === '' || str_ends_with($key, ')') || str_ends_with($key, "'") || str_ends_with($key, '"')) { return null; } 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%'), + ]); +}