From 4ed53f2a88459e03f27b385f5580fe3005fb0d1d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 30 Jan 2023 19:09:39 +1300 Subject: [PATCH 1/2] Add `like` and `notLike` operators base --- src/Database/Adapter/Mongo.php | 55 +++++++++++--------------- src/Database/Adapter/SQL.php | 23 +++++------ src/Database/Query.php | 45 +++++++++------------ tests/Database/Base.php | 19 +++++++++ tests/Database/QueryTest.php | 12 ++++++ tests/Database/Validator/QueryTest.php | 2 + 6 files changed, 84 insertions(+), 72 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 49cccb878..5285c1b8a 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -941,7 +941,7 @@ protected function buildFilters($queries): array { $filters = []; - foreach ($queries as $i => $query) { + foreach ($queries as $query) { if ($query->getAttribute() === '$id') { $query->setAttribute('_uid'); } @@ -957,27 +957,29 @@ protected function buildFilters($queries): array $attribute = $query->getAttribute(); $operator = $this->getQueryOperator($query->getMethod()); - switch ($query->getMethod()) { - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - $value = null; + $value = match ($query->getMethod()) { + Query::TYPE_IS_NULL, + Query::TYPE_IS_NOT_NULL => null, + default => count($query->getValues()) > 1 + ? $query->getValues() + : $query->getValues()[0], + }; - break; - default: - $value = count($query->getValues()) > 1 - ? $query->getValues() - : $query->getValues()[0]; - - break; - } + $negated = match ($query->getMethod()) { + Query::TYPE_NOT_LIKE => true, + default => false, + }; - if (is_array($value) && $operator === '$eq') { + if ($operator == '$eq' && \is_array($value)) { $filters[$attribute]['$in'] = $value; - } elseif ($operator === '$in') { + } else if ($operator == '$ne' && \is_array($value)) { + $filters[$attribute]['$nin'] = $value; + } else if($operator == '$in') { $filters[$attribute]['$in'] = $query->getValues(); - } elseif ($operator === '$search') { - // only one fulltext index per mongo collection, so attribute not necessary + } else if ($operator == '$search') { $filters['$text'][$operator] = $value; + } else if ($negated) { + $filters[$attribute]['$not'][$operator] = $value; } else { $filters[$attribute][$operator] = $value; } @@ -997,35 +999,26 @@ protected function getQueryOperator(string $operator): string { switch ($operator) { case Query::TYPE_EQUAL: + case Query::TYPE_IS_NULL: return '$eq'; - case Query::TYPE_NOTEQUAL: + case Query::TYPE_IS_NOT_NULL: return '$ne'; - case Query::TYPE_LESSER: return '$lt'; - case Query::TYPE_LESSEREQUAL: return '$lte'; - case Query::TYPE_GREATER: return '$gt'; - case Query::TYPE_GREATEREQUAL: return '$gte'; - case Query::TYPE_CONTAINS: return '$in'; - case Query::TYPE_SEARCH: return '$search'; - - case Query::TYPE_IS_NULL: - return '$eq'; - - case Query::TYPE_IS_NOT_NULL: - return '$ne'; - + case Query::TYPE_LIKE: + case Query::TYPE_NOT_LIKE: + return '$regex'; default: throw new Exception('Unknown Operator:' . $operator); } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index ad354cdca..027113758 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -685,31 +685,26 @@ protected function getSQLOperator(string $method): string switch ($method) { case Query::TYPE_EQUAL: return '='; - case Query::TYPE_NOTEQUAL: return '!='; - case Query::TYPE_LESSER: return '<'; - case Query::TYPE_LESSEREQUAL: return '<='; - case Query::TYPE_GREATER: return '>'; - case Query::TYPE_GREATEREQUAL: return '>='; - case Query::TYPE_IS_NULL: return 'IS NULL'; - case Query::TYPE_IS_NOT_NULL: return 'IS NOT NULL'; - + case Query::TYPE_LIKE: + return 'LIKE'; + case Query::TYPE_NOT_LIKE: + return 'NOT LIKE'; default: throw new Exception('Unknown method:' . $method); - break; } } @@ -750,11 +745,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 fc76c0329..0b3ef61ce 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -17,6 +17,8 @@ class Query const TYPE_SEARCH = 'search'; const TYPE_IS_NULL = 'isNull'; const TYPE_IS_NOT_NULL = 'isNotNull'; + const TYPE_LIKE = 'like'; + const TYPE_NOT_LIKE = 'notLike'; // Order methods const TYPE_ORDERDESC = 'orderDesc'; @@ -129,7 +131,7 @@ public function setValue($value): self */ public static function isMethod(string $value): bool { - return match (static::getMethodFromAlias($value)) { + return match ($value) { self::TYPE_EQUAL, self::TYPE_NOTEQUAL, self::TYPE_LESSER, @@ -145,10 +147,11 @@ public static function isMethod(string $value): bool self::TYPE_CURSORAFTER, self::TYPE_CURSORBEFORE, self::TYPE_IS_NULL, - self::TYPE_IS_NOT_NULL => true, + self::TYPE_IS_NOT_NULL, + self::TYPE_LIKE, + self::TYPE_NOT_LIKE => true, default => false, }; - } /** @@ -289,8 +292,6 @@ public static function parse(string $filter): self } } - $method = static::getMethodFromAlias($method); - switch ($method) { case self::TYPE_EQUAL: case self::TYPE_NOTEQUAL: @@ -302,6 +303,8 @@ public static function parse(string $filter): self case self::TYPE_SEARCH: case self::TYPE_IS_NULL: case self::TYPE_IS_NOT_NULL: + case self::TYPE_LIKE: + case self::TYPE_NOT_LIKE: $attribute = $parsedParams[0] ?? ''; if (count($parsedParams) < 2) { return new self($method, $attribute); @@ -412,28 +415,6 @@ protected static function parseValue(string $value): mixed return $value; } - /** - * Returns Method from Alias. - * - * @param string $method - * @return string - */ - static protected function getMethodFromAlias(string $method): string - { - return $method; - /* - Commented out as we didn't consider this important at the moment, since IDE autocomplete should do the job. - return match ($method) { - 'lt' => self::TYPE_LESSER, - 'lte' => self::TYPE_LESSEREQUAL, - 'gt' => self::TYPE_GREATER, - 'gte' => self::TYPE_GREATEREQUAL, - 'eq' => self::TYPE_EQUAL, - default => $method - }; - */ - } - /** * Helper method to create Query with equal method */ @@ -556,6 +537,16 @@ public static function isNotNull(string $attribute): self return new self(self::TYPE_IS_NOT_NULL, $attribute); } + public static function like(string $attribute, string $value): self + { + return new self(self::TYPE_LIKE, $attribute, [$value]); + } + + public static function notLike(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_LIKE, $attribute, [$value]); + } + /** * Filters $queries for $types * diff --git a/tests/Database/Base.php b/tests/Database/Base.php index a4a95975e..2dde0a42c 100644 --- a/tests/Database/Base.php +++ b/tests/Database/Base.php @@ -4,6 +4,7 @@ use Exception; use PHPUnit\Framework\TestCase; +use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -1993,6 +1994,24 @@ public function testFindNotNull() $this->assertEquals(1, count($documents)); } + public function testFindLike() + { + $documents = static::getDatabase()->find('movies', [ + Query::like('name', 'Frozen%'), + ]); + + $this->assertEquals(2, count($documents)); + } + + public function testFindNotLike() + { + $documents = static::getDatabase()->find('movies', [ + Query::notLike('name', 'Frozen%'), + ]); + + $this->assertEquals(4, count($documents)); + } + /** * @depends testFind */ diff --git a/tests/Database/QueryTest.php b/tests/Database/QueryTest.php index 7fffda864..9c4548158 100644 --- a/tests/Database/QueryTest.php +++ b/tests/Database/QueryTest.php @@ -163,6 +163,18 @@ public function testParse() $this->assertEquals('isNotNull', $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); $this->assertEquals([], $query->getValues()); + + $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() diff --git a/tests/Database/Validator/QueryTest.php b/tests/Database/Validator/QueryTest.php index 3fc13255a..2c27e3fcd 100644 --- a/tests/Database/Validator/QueryTest.php +++ b/tests/Database/Validator/QueryTest.php @@ -120,6 +120,8 @@ public function testQuery() $this->assertEquals(true, $validator->isValid(DatabaseQuery::parse('orderDesc("title")'))); $this->assertEquals(true, $validator->isValid(DatabaseQuery::parse('isNull("title")'))); $this->assertEquals(true, $validator->isValid(DatabaseQuery::parse('isNotNull("title")'))); + $this->assertEquals(true, $validator->isValid(DatabaseQuery::parse('like("title", "Iron Man")'))); + $this->assertEquals(true, $validator->isValid(DatabaseQuery::parse('notLike("title", "Iron Man")'))); } public function testInvalidMethod() From e56418ec82cfdb8c6acf7ef9e265093bf09e2b6b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 29 Sep 2023 00:21:19 +1300 Subject: [PATCH 2/2] Add regex operator --- src/Database/Adapter/Mongo.php | 7 +++- src/Database/Adapter/SQL.php | 53 ++++++++++--------------- src/Database/Query.php | 26 +++++++++--- src/Database/Validator/Queries.php | 5 ++- src/Database/Validator/Query/Filter.php | 3 ++ tests/Database/Base.php | 15 +++++-- 6 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 6a47cb8f1..374c584be 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1334,7 +1334,8 @@ protected function getQueryOperator(string $operator): string Query::TYPE_STARTS_WITH, Query::TYPE_ENDS_WITH, Query::TYPE_LIKE, - Query::TYPE_NOT_LIKE => '$regex', + 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), }; } @@ -1348,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 29cd0af58..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,32 +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: - case Query::TYPE_LIKE: - return 'LIKE'; - case Query::TYPE_NOT_LIKE: - return 'NOT 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), + }; } /** @@ -840,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 20a7d48ae..e48e15087 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -22,6 +22,7 @@ class Query 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'; @@ -53,7 +54,6 @@ class Query */ protected array $values = []; - /** * Construct a new query object * @@ -346,6 +346,7 @@ public static function parse(string $filter): self 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); @@ -700,14 +701,29 @@ public static function endsWith(string $attribute, string $value): self return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); } - public static function like(string $attribute, string $value): self + /** + * @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_LIKE, $attribute, [$value]); + return new self(self::TYPE_NOT_LIKE, $attribute, $values); } - public static function notLike(string $attribute, string $value): self + public static function regex(string $attribute, string $value): self { - return new self(self::TYPE_NOT_LIKE, $attribute, [$value]); + return new self(self::TYPE_REGEX, $attribute, [$value]); } /** 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 4d2684157..8304ef4d4 100644 --- a/tests/Database/Base.php +++ b/tests/Database/Base.php @@ -2953,7 +2953,7 @@ public function testFindSelect(): void public function testFindLike(): void { $documents = static::getDatabase()->find('movies', [ - Query::like('name', 'Frozen%'), + Query::like('name', ['Frozen']), ]); $this->assertEquals(2, count($documents)); @@ -2962,10 +2962,19 @@ public function testFindLike(): void public function testFindNotLike(): void { $documents = static::getDatabase()->find('movies', [ - Query::notLike('name', 'Frozen%'), + Query::notLike('name', ['Frozen']), ]); - $this->assertEquals(4, count($documents)); + $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)); } /**