diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index df827a405..374c584be 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1283,6 +1283,11 @@ protected function buildFilters(array $queries): array ), }; + $negated = match ($query->getMethod()) { + Query::TYPE_NOT_LIKE => true, + default => false, + }; + if ($operator == '$eq' && \is_array($value)) { $filters[$attribute]['$in'] = $value; } elseif ($operator == '$ne' && \is_array($value)) { @@ -1294,6 +1299,8 @@ protected function buildFilters(array $queries): array } elseif ($operator === Query::TYPE_BETWEEN) { $filters[$attribute]['$lte'] = $value[1]; $filters[$attribute]['$gte'] = $value[0]; + } elseif ($negated) { + $filters[$attribute]['$not'][$operator] = $value; } else { $filters[$attribute][$operator] = $value; } @@ -1325,7 +1332,10 @@ protected function getQueryOperator(string $operator): string Query::TYPE_SEARCH => '$search', Query::TYPE_BETWEEN => 'between', Query::TYPE_STARTS_WITH, - Query::TYPE_ENDS_WITH => '$regex', + Query::TYPE_ENDS_WITH, + Query::TYPE_LIKE, + Query::TYPE_NOT_LIKE, + Query::TYPE_REGEX => '$regex', default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_SELECT), }; } @@ -1339,6 +1349,10 @@ protected function getQueryValue(string $method, mixed $value): mixed case Query::TYPE_ENDS_WITH: $value = $this->escapeWildcards($value); return '.*'.$value; + case Query::TYPE_LIKE: + case Query::TYPE_NOT_LIKE: + $value = $this->escapeWildcards($value); + return '.*'.$value.'.*'; default: return $value; } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 8525d2738..c2ba06f2f 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -702,6 +702,7 @@ protected function bindConditionValue(mixed $stmt, Query $query): void $value = match ($query->getMethod()) { Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_LIKE => '%' . $this->escapeWildcards($value) . '%', Query::TYPE_SEARCH => $this->getFulltextValue($value), default => $value }; @@ -748,29 +749,22 @@ protected function getFulltextValue(string $value): string */ protected function getSQLOperator(string $method): string { - switch ($method) { - case Query::TYPE_EQUAL: - return '='; - case Query::TYPE_NOT_EQUAL: - return '!='; - case Query::TYPE_LESSER: - return '<'; - case Query::TYPE_LESSER_EQUAL: - return '<='; - case Query::TYPE_GREATER: - return '>'; - case Query::TYPE_GREATER_EQUAL: - return '>='; - case Query::TYPE_IS_NULL: - return 'IS NULL'; - case Query::TYPE_IS_NOT_NULL: - return 'IS NOT NULL'; - case Query::TYPE_STARTS_WITH: - case Query::TYPE_ENDS_WITH: - return 'LIKE'; - default: - throw new DatabaseException('Unknown method: ' . $method); - } + return match ($method) { + Query::TYPE_EQUAL => '=', + Query::TYPE_NOT_EQUAL => '!=', + Query::TYPE_LESSER => '<', + Query::TYPE_LESSER_EQUAL => '<=', + Query::TYPE_GREATER => '>', + Query::TYPE_GREATER_EQUAL => '>=', + Query::TYPE_IS_NULL => 'IS NULL', + Query::TYPE_IS_NOT_NULL => 'IS NOT NULL', + Query::TYPE_STARTS_WITH, + Query::TYPE_ENDS_WITH, + Query::TYPE_LIKE => 'LIKE', + Query::TYPE_NOT_LIKE => 'NOT LIKE', + Query::TYPE_REGEX => 'REGEXP', + default => throw new DatabaseException('Unknown method: ' . $method), + }; } /** @@ -837,11 +831,11 @@ protected function getSQLPermissionsCondition(string $collection, array $roles): { $roles = array_map(fn (string $role) => $this->getPDO()->quote($role), $roles); return "table_main._uid IN ( - SELECT distinct(_document) - FROM {$this->getSQLTable($collection . '_perms')} - WHERE _permission IN (" . implode(', ', $roles) . ") - AND _type = 'read' - )"; + SELECT distinct(_document) + FROM {$this->getSQLTable($collection . '_perms')} + WHERE _permission IN (" . implode(', ', $roles) . ") + AND _type = 'read' + )"; } /** diff --git a/src/Database/Query.php b/src/Database/Query.php index f2ed42416..e48e15087 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -20,6 +20,9 @@ class Query public const TYPE_BETWEEN = 'between'; public const TYPE_STARTS_WITH = 'startsWith'; public const TYPE_ENDS_WITH = 'endsWith'; + public const TYPE_LIKE = 'like'; + public const TYPE_NOT_LIKE = 'notLike'; + public const TYPE_REGEX = 'regex'; public const TYPE_SELECT = 'select'; @@ -51,7 +54,6 @@ class Query */ protected array $values = []; - /** * Construct a new query object * @@ -158,7 +160,7 @@ public function setValue(mixed $value): self */ public static function isMethod(string $value): bool { - return match (static::getMethodFromAlias($value)) { + return match ($value) { self::TYPE_EQUAL, self::TYPE_NOT_EQUAL, self::TYPE_LESSER, @@ -178,6 +180,8 @@ public static function isMethod(string $value): bool self::TYPE_BETWEEN, self::TYPE_STARTS_WITH, self::TYPE_ENDS_WITH, + self::TYPE_LIKE, + self::TYPE_NOT_LIKE, self::TYPE_SELECT => true, default => false, }; @@ -340,6 +344,9 @@ public static function parse(string $filter): self case self::TYPE_IS_NOT_NULL: case self::TYPE_STARTS_WITH: case self::TYPE_ENDS_WITH: + case self::TYPE_LIKE: + case self::TYPE_NOT_LIKE: + case self::TYPE_REGEX: $attribute = $parsedParams[0] ?? ''; if (count($parsedParams) < 2) { return new self($method, $attribute); @@ -694,6 +701,31 @@ public static function endsWith(string $attribute, string $value): self return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); } + /** + * @param string $attribute + * @param array $values + * @return self + */ + public static function like(string $attribute, array $values): self + { + return new self(self::TYPE_LIKE, $attribute, $values); + } + + /** + * @param string $attribute + * @param array $values + * @return self + */ + public static function notLike(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_LIKE, $attribute, $values); + } + + public static function regex(string $attribute, string $value): self + { + return new self(self::TYPE_REGEX, $attribute, [$value]); + } + /** * Filters $queries for $types * diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index c80a18366..3d89a4206 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -82,7 +82,10 @@ public function isValid($value): bool Query::TYPE_BETWEEN, Query::TYPE_STARTS_WITH, Query::TYPE_CONTAINS, - Query::TYPE_ENDS_WITH => Base::METHOD_TYPE_FILTER, + Query::TYPE_ENDS_WITH, + Query::TYPE_LIKE, + Query::TYPE_NOT_LIKE, + Query::TYPE_REGEX => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9eab6b28e..fb1684663 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -138,6 +138,8 @@ public function isValid($value): bool switch ($method) { case Query::TYPE_EQUAL: case Query::TYPE_CONTAINS: + case Query::TYPE_LIKE: + case Query::TYPE_NOT_LIKE: if ($this->isEmpty($value->getValues())) { $this->message = \ucfirst($method) . ' queries require at least one value.'; return false; @@ -153,6 +155,7 @@ public function isValid($value): bool case Query::TYPE_SEARCH: case Query::TYPE_STARTS_WITH: case Query::TYPE_ENDS_WITH: + case Query::TYPE_REGEX: if (count($value->getValues()) != 1) { $this->message = \ucfirst($method) . ' queries require exactly one value.'; return false; diff --git a/tests/Database/Base.php b/tests/Database/Base.php index 34609d113..8304ef4d4 100644 --- a/tests/Database/Base.php +++ b/tests/Database/Base.php @@ -2950,6 +2950,33 @@ public function testFindSelect(): void } } + public function testFindLike(): void + { + $documents = static::getDatabase()->find('movies', [ + Query::like('name', ['Frozen']), + ]); + + $this->assertEquals(2, count($documents)); + } + + public function testFindNotLike(): void + { + $documents = static::getDatabase()->find('movies', [ + Query::notLike('name', ['Frozen']), + ]); + + $this->assertEquals(5, count($documents)); + } + + public function testFindRegex(): void + { + $documents = static::getDatabase()->find('movies', [ + Query::regex('name', 'Fr..en\sII'), + ]); + + $this->assertEquals(1, count($documents)); + } + /** * @depends testFind */ diff --git a/tests/Database/QueryTest.php b/tests/Database/QueryTest.php index a7e497959..4c12ad212 100644 --- a/tests/Database/QueryTest.php +++ b/tests/Database/QueryTest.php @@ -195,6 +195,18 @@ public function testParse(): void $this->assertEquals('lastUpdate', $query->getAttribute()); $this->assertEquals('DATE1', $query->getValues()[0]); $this->assertEquals('DATE2', $query->getValues()[1]); + + $query = Query::parse('like("director", "Tester")'); + + $this->assertEquals('like', $query->getMethod()); + $this->assertEquals('director', $query->getAttribute()); + $this->assertEquals(["Tester"], $query->getValues()); + + $query = Query::parse('notLike("director", "Tester")'); + + $this->assertEquals('notLike', $query->getMethod()); + $this->assertEquals('director', $query->getAttribute()); + $this->assertEquals(["Tester"], $query->getValues()); } public function testParseV2(): void diff --git a/tests/Database/Validator/QueryTest.php b/tests/Database/Validator/QueryTest.php index 76f78c1a5..f1571ef6e 100644 --- a/tests/Database/Validator/QueryTest.php +++ b/tests/Database/Validator/QueryTest.php @@ -131,6 +131,8 @@ public function testQuery(): void $this->assertEquals(true, $validator->isValid([Query::parse('endsWith("title", "Zen")')])); $this->assertEquals(true, $validator->isValid([Query::parse('select(["title", "description"])')])); $this->assertEquals(true, $validator->isValid([Query::parse('notEqual("title", [""])')])); + $this->assertEquals(true, $validator->isValid(Query::parse('like("title", "Iron Man")'))); + $this->assertEquals(true, $validator->isValid(Query::parse('notLike("title", "Iron Man")'))); } /**