diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index f1f3356ff..372c8eea9 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -25,7 +25,6 @@ use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\Permissions; -use Utopia\Database\Validator\Query as QueryValidator; use Utopia\Database\Validator\Structure; use Utopia\Database\Validator\UID; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -38,8 +37,12 @@ use Appwrite\Network\Validator\Email; use Appwrite\Network\Validator\IP; use Appwrite\Network\Validator\URL; use Appwrite\Utopia\Database\Validator\CustomId; -use Appwrite\Utopia\Database\Validator\Queries as QueriesValidator; -use Appwrite\Utopia\Database\Validator\OrderAttributes; +use Appwrite\Utopia\Database\Validator\IndexedQueries; +use Appwrite\Utopia\Database\Validator\Query\Cursor as CursorQueryValidator; +use Appwrite\Utopia\Database\Validator\Query\Filter as FilterQueryValidator; +use Appwrite\Utopia\Database\Validator\Query\Limit as LimitQueryValidator; +use Appwrite\Utopia\Database\Validator\Query\Offset as OffsetQueryValidator; +use Appwrite\Utopia\Database\Validator\Query\Order as OrderQueryValidator; use Appwrite\Utopia\Response; use Appwrite\Detector\Detector; use Appwrite\Event\Database as EventDatabase; @@ -2028,15 +2031,42 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') throw new Exception(Exception::USER_UNAUTHORIZED); } - $filterQueries = \array_map(function ($query) { - $query = Query::parse($query); - - if (\count($query->getValues()) > 100) { - throw new Exception(Exception::GENERAL_QUERY_LIMIT_EXCEEDED, "You cannot use more than 100 query values on attribute '{$query->getAttribute()}'"); + if (!empty($queries)) { + $attributes = array_merge( + $collection->getAttribute('attributes', []), + [ + new Document([ + 'key' => '$id', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + new Document([ + 'key' => '$createdAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]), + new Document([ + 'key' => '$updatedAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]), + ] + ); + $validator = new IndexedQueries( + $attributes, + $collection->getAttribute('indexes', []), + new CursorQueryValidator(), + new FilterQueryValidator($attributes), + new LimitQueryValidator(), + new OffsetQueryValidator(), + new OrderQueryValidator(), + ); + if (!$validator->isValid($queries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); } + } - return $query; - }, $queries); + $filterQueries = Query::parseQueries($queries); $otherQueries = []; $otherQueries[] = Query::limit($limit); @@ -2060,14 +2090,6 @@ App::get('/v1/databases/:databaseId/collections/:collectionId/documents') $allQueries = \array_merge($filterQueries, $otherQueries); - if (!empty($allQueries)) { - $attributes = $collection->getAttribute('attributes', []); - $validator = new QueriesValidator(new QueryValidator($attributes), $attributes, $collection->getAttribute('indexes', []), true); - if (!$validator->isValid($allQueries)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - } - if ($documentSecurity) { $documents = $dbForProject->find('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $allQueries); $total = $dbForProject->count('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $filterQueries, APP_LIMIT_COUNT); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index dd792aecc..3856faeee 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -9,7 +9,10 @@ use Appwrite\Event\Event; use Appwrite\Network\Validator\Email; use Appwrite\Stats\Stats; use Appwrite\Utopia\Database\Validator\CustomId; +use Appwrite\Utopia\Database\Validator\Queries; use Appwrite\Utopia\Database\Validator\Queries\Users; +use Appwrite\Utopia\Database\Validator\Query\Limit; +use Appwrite\Utopia\Database\Validator\Query\Offset; use Appwrite\Utopia\Response; use Utopia\App; use Utopia\Audit\Audit; @@ -29,7 +32,6 @@ use Utopia\Database\Validator\Authorization; use Utopia\Validator\Assoc; use Utopia\Validator\WhiteList; use Utopia\Validator\Text; -use Utopia\Validator\Range; use Utopia\Validator\Boolean; use MaxMind\Db\Reader; use Utopia\Validator\Integer; @@ -573,14 +575,13 @@ App::get('/v1/users/:userId/logs') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_LOG_LIST) ->param('userId', '', new UID(), 'User ID.') - ->param('limit', 25, new Range(0, 100), 'Maximum number of logs to return in response. By default will return maximum 25 results. Maximum of 100 results allowed per request.', true) - ->param('offset', 0, new Range(0, APP_LIMIT_COUNT), 'Offset value. The default value is 0. Use this value to manage pagination. [learn more about pagination](https://appwrite.io/docs/pagination)', true) + ->param('queries', [], new Queries(new Limit(), new Offset()), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Only supported methods are limit and offset', true) ->inject('response') ->inject('dbForProject') ->inject('locale') ->inject('geodb') ->inject('usage') - ->action(function (string $userId, int $limit, int $offset, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Stats $usage) { + ->action(function (string $userId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb, Stats $usage) { $user = $dbForProject->getDocument('users', $userId); @@ -588,6 +589,11 @@ App::get('/v1/users/:userId/logs') throw new Exception(Exception::USER_NOT_FOUND); } + $queries = Query::parseQueries($queries); + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? 25; + $offset = $grouped['offset'] ?? 0; + $audit = new Audit($dbForProject); $logs = $audit->getLogsByUser($user->getId(), $limit, $offset); diff --git a/composer.lock b/composer.lock index c3f58085e..b9176fe53 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5d5b72c7940a8376e187cce1b33d327a", + "content-hash": "0e850206a924d2a48861ecd290f59bc0", "packages": [ { "name": "adhocore/jwt", @@ -3526,23 +3526,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.15", + "version": "9.2.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2e9da11878c4202f97915c1cb4bb1ca318a63f5f" + "reference": "2593003befdcc10db5e213f9f28814f5aa8ac073" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2e9da11878c4202f97915c1cb4bb1ca318a63f5f", - "reference": "2e9da11878c4202f97915c1cb4bb1ca318a63f5f", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2593003befdcc10db5e213f9f28814f5aa8ac073", + "reference": "2593003befdcc10db5e213f9f28814f5aa8ac073", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.13.0", + "nikic/php-parser": "^4.14", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -3591,7 +3591,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.15" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.16" }, "funding": [ { @@ -3599,7 +3599,7 @@ "type": "github" } ], - "time": "2022-03-07T09:28:20+00:00" + "time": "2022-08-20T05:26:47+00:00" }, { "name": "phpunit/php-file-iterator", diff --git a/src/Appwrite/Utopia/Database/Validator/IndexedQueries.php b/src/Appwrite/Utopia/Database/Validator/IndexedQueries.php new file mode 100644 index 000000000..1cc042901 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/IndexedQueries.php @@ -0,0 +1,153 @@ +attributes = $attributes; + + $this->indexes[] = new Document([ + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['$id'] + ]); + + $this->indexes[] = new Document([ + 'type' => Database::INDEX_KEY, + 'attributes' => ['$createdAt'] + ]); + + $this->indexes[] = new Document([ + 'type' => Database::INDEX_KEY, + 'attributes' => ['$updatedAt'] + ]); + + foreach ($indexes ?? [] as $index) { + $this->indexes[] = $index; + } + + parent::__construct(...$validators); + } + + /** + * Check if indexed array $indexes matches $queries + * + * @param array $indexes + * @param array $queries + * + * @return bool + */ + protected function arrayMatch(array $indexes, array $queries): bool + { + // Check the count of indexes first for performance + if (count($queries) !== count($indexes)) { + return false; + } + + // Sort them for comparison, the order is not important here anymore. + sort($indexes, SORT_STRING); + sort($queries, SORT_STRING); + + // Only matching arrays will have equal diffs in both directions + if (array_diff_assoc($indexes, $queries) !== array_diff_assoc($queries, $indexes)) { + return false; + } + + return true; + } + + /** + * Is valid. + * + * Returns false if: + * 1. any query in $value is invalid based on $validator + * 2. there is no index with an exact match of the filters + * 3. there is no index with an exact match of the order attributes + * + * Otherwise, returns true. + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + if (!parent::isValid($value)) { + return false; + } + + $queries = []; + foreach ($value as $query) { + if (!$query instanceof Query) { + $query = Query::parse($query); + } + + $queries[] = $query; + } + + $grouped = Query::groupByType($queries); + /** @var Query[] */ $filters = $grouped['filters']; + /** @var string[] */ $orderAttributes = $grouped['orderAttributes']; + + // Check filter queries for exact index match + if (count($filters) > 0) { + $filtersByAttribute = []; + foreach ($filters as $filter) { + $filtersByAttribute[$filter->getAttribute()] = $filter->getMethod(); + } + + $found = null; + + foreach ($this->indexes as $index) { + if ($this->arrayMatch($index->getAttribute('attributes'), array_keys($filtersByAttribute))) { + $found = $index; + } + } + + if (!$found) { + $this->message = 'Index not found: ' . implode(",", array_keys($filtersByAttribute)); + return false; + } + + // search method requires fulltext index + if (in_array(Query::TYPE_SEARCH, array_values($filtersByAttribute)) && $found['type'] !== Database::INDEX_FULLTEXT) { + $this->message = 'Search method requires fulltext index: ' . implode(",", array_keys($filtersByAttribute)); + return false; + } + } + + // Check order attributes for exact index match + $validator = new OrderAttributes($this->attributes, $this->indexes, true); + if (count($orderAttributes) > 0 && !$validator->isValid($orderAttributes)) { + $this->message = $validator->getDescription(); + return false; + } + + return true; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries.php b/src/Appwrite/Utopia/Database/Validator/Queries.php index 8ea70231d..ec7797149 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries.php @@ -2,28 +2,139 @@ namespace Appwrite\Utopia\Database\Validator; -use Utopia\Database\Document; -use Utopia\Database\Validator\Queries as ValidatorQueries; +use Appwrite\Utopia\Database\Validator\Query\Base; +use Utopia\Validator; +use Utopia\Database\Query; -class Queries extends ValidatorQueries +class Queries extends Validator { /** - * Expression constructor - * - * This Queries Validator that filters indexes for only available indexes - * - * @param QueryValidator $validator - * @param Document[] $attributes - * @param Document[] $indexes - * @param bool $strict + * @var string */ - public function __construct($validator, $attributes = [], $indexes = [], $strict = true) - { - // Remove failed/stuck/processing indexes - $availableIndexes = \array_filter($indexes, function ($index) { - return $index->getAttribute('status') === 'available'; - }); + protected $message = 'Invalid queries'; - parent::__construct($validator, $attributes, $availableIndexes, $strict); + /** + * @var Base[] + */ + protected $validators; + + /** + * Queries constructor + * + * @param Base ...$validators a list of validators + */ + public function __construct(Base ...$validators) + { + $this->validators = $validators; + } + + /** + * Get Description. + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return $this->message; + } + + /** + * Is valid. + * + * Returns false if: + * 1. any query in $value is invalid based on $validator + * + * Otherwise, returns true. + * + * @param mixed $value + * @return bool + */ + public function isValid($value): bool + { + foreach ($value as $query) { + if (!$query instanceof Query) { + try { + $query = Query::parse($query); + } catch (\Throwable $th) { + $this->message = 'Invalid query: ${query}'; + return false; + } + } + + $method = $query->getMethod(); + $methodType = ''; + switch ($method) { + case Query::TYPE_LIMIT: + $methodType = Base::METHOD_TYPE_LIMIT; + break; + case Query::TYPE_OFFSET: + $methodType = Base::METHOD_TYPE_OFFSET; + break; + case Query::TYPE_CURSORAFTER: + case Query::TYPE_CURSORBEFORE: + $methodType = Base::METHOD_TYPE_CURSOR; + break; + case Query::TYPE_ORDERASC: + case Query::TYPE_ORDERDESC: + $methodType = Base::METHOD_TYPE_ORDER; + break; + case Query::TYPE_EQUAL: + case Query::TYPE_NOTEQUAL: + case Query::TYPE_LESSER: + case Query::TYPE_LESSEREQUAL: + case Query::TYPE_GREATER: + case Query::TYPE_GREATEREQUAL: + case Query::TYPE_SEARCH: + $methodType = Base::METHOD_TYPE_FILTER; + break; + default: + break; + } + + $methodIsValid = false; + foreach ($this->validators as $validator) { + if ($validator->getMethodType() !== $methodType) { + continue; + } + if (!$validator->isValid($query)) { + $this->message = 'Query not valid: ' . $validator->getDescription(); + return false; + } + + $methodIsValid = true; + } + + if (!$methodIsValid) { + $this->message = 'Query method not valid: ' . $method; + return false; + } + } + + return true; + } + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return true; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Collection.php b/src/Appwrite/Utopia/Database/Validator/Queries/Collection.php index 0404f299a..0c29ecd2f 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Collection.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Collection.php @@ -2,13 +2,17 @@ namespace Appwrite\Utopia\Database\Validator\Queries; +use Appwrite\Utopia\Database\Validator\IndexedQueries; +use Appwrite\Utopia\Database\Validator\Query\Limit; +use Appwrite\Utopia\Database\Validator\Query\Offset; +use Appwrite\Utopia\Database\Validator\Query\Cursor; +use Appwrite\Utopia\Database\Validator\Query\Filter; +use Appwrite\Utopia\Database\Validator\Query\Order; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Validator\Queries as QueriesValidator; -use Utopia\Database\Validator\Query as QueryValidator; -class Collection extends QueriesValidator +class Collection extends IndexedQueries { /** * Expression constructor @@ -39,6 +43,22 @@ class Collection extends QueriesValidator ]); } + $attributes[] = new Document([ + 'key' => '$id', + 'type' => Database::VAR_STRING, + 'array' => false, + ]); + $attributes[] = new Document([ + '$id' => '$createdAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]); + $attributes[] = new Document([ + '$id' => '$updatedAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]); + $indexes = []; foreach ($allowedAttributes as $attribute) { $indexes[] = new Document([ @@ -53,6 +73,14 @@ class Collection extends QueriesValidator 'attributes' => ['search'] ]); - parent::__construct(new QueryValidator($attributes), $attributes, $indexes, true); + $validators = [ + new Limit(), + new Offset(), + new Cursor(), + new Filter($attributes), + new Order($attributes), + ]; + + parent::__construct($attributes, $indexes, ...$validators); } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Users.php b/src/Appwrite/Utopia/Database/Validator/Queries/Users.php index fcef3f992..ffe30f120 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Users.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Users.php @@ -15,6 +15,7 @@ class Users extends Collection 'registration', 'emailVerification', 'phoneVerification', + 'search', ]; /** diff --git a/src/Appwrite/Utopia/Database/Validator/Query.php b/src/Appwrite/Utopia/Database/Validator/Query.php deleted file mode 100644 index eeb5b51a7..000000000 --- a/src/Appwrite/Utopia/Database/Validator/Query.php +++ /dev/null @@ -1,21 +0,0 @@ -isValid($cursor)) { - return true; - } - - $this->message = 'Invalid cursor: ' . $validator->getDescription(); - return false; - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Query/Base.php b/src/Appwrite/Utopia/Database/Validator/Query/Base.php new file mode 100644 index 000000000..b23d594ea --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Query/Base.php @@ -0,0 +1,70 @@ +message; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } + + /** + * Is valid. + * + * @param Query $value + * + * @return bool + */ + abstract public function isValid($query): bool; + + /** + * Returns what type of query this Validator is for + */ + abstract public function getMethodType(): string; +} diff --git a/src/Appwrite/Utopia/Database/Validator/Query/Cursor.php b/src/Appwrite/Utopia/Database/Validator/Query/Cursor.php new file mode 100644 index 000000000..42bff08a1 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Query/Cursor.php @@ -0,0 +1,44 @@ +getMethod(); + + if ($method === Query::TYPE_CURSORAFTER || $method === Query::TYPE_CURSORBEFORE) { + $cursor = $query->getValue(); + $validator = new UID(); + if ($validator->isValid($cursor)) { + return true; + } + $this->message = 'Invalid cursor: ' . $validator->getDescription(); + return false; + } + + return false; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_CURSOR; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Query/Filter.php b/src/Appwrite/Utopia/Database/Validator/Query/Filter.php new file mode 100644 index 000000000..096d03690 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Query/Filter.php @@ -0,0 +1,114 @@ +schema[$attribute->getAttribute('key')] = $attribute->getArrayCopy(); + } + + $this->maxValuesCount = $maxValuesCount; + } + + protected function isValidAttribute($attribute): bool + { + // Search for attribute in schema + if (!isset($this->schema[$attribute])) { + $this->message = 'Attribute not found in schema: ' . $attribute; + return false; + } + + return true; + } + + protected function isValidAttributeAndValues(string $attribute, array $values): bool + { + if (!$this->isValidAttribute($attribute)) { + return false; + } + + $attributeSchema = $this->schema[$attribute]; + + if (count($values) > $this->maxValuesCount) { + $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; + return false; + } + + // Extract the type of desired attribute from collection $schema + $attributeType = $attributeSchema['type']; + + foreach ($values as $value) { + $condition = match ($attributeType) { + Database::VAR_DATETIME => gettype($value) === Database::VAR_STRING, + default => gettype($value) === $attributeType + }; + + if (!$condition) { + $this->message = 'Query type does not match expected: ' . $attributeType; + return false; + } + } + + return true; + } + + /** + * Is valid. + * + * Returns true if method is a filter method, attribute exists, and value matches attribute type + * + * Otherwise, returns false + * + * @param Query $value + * + * @return bool + */ + public function isValid($query): bool + { + // Validate method + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + switch ($method) { + case Query::TYPE_EQUAL: + case Query::TYPE_NOTEQUAL: + case Query::TYPE_LESSER: + case Query::TYPE_LESSEREQUAL: + case Query::TYPE_GREATER: + case Query::TYPE_GREATEREQUAL: + case Query::TYPE_SEARCH: + $values = $query->getValues(); + return $this->isValidAttributeAndValues($attribute, $values); + + default: + return false; + } + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_FILTER; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Query/Limit.php b/src/Appwrite/Utopia/Database/Validator/Query/Limit.php new file mode 100644 index 000000000..232df9366 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Query/Limit.php @@ -0,0 +1,61 @@ +maxLimit = $maxLimit; + } + + protected function isValidLimit($limit): bool + { + $validator = new Range(0, $this->maxLimit); + if ($validator->isValid($limit)) { + return true; + } + + $this->message = 'Invalid limit: ' . $validator->getDescription(); + return false; + } + + /** + * Is valid. + * + * Returns true if method is limit values are within range. + * + * @param Query $value + * + * @return bool + */ + public function isValid($query): bool + { + // Validate method + $method = $query->getMethod(); + + if ($method !== Query::TYPE_LIMIT) { + $this->message = 'Query method invalid: ' . $method; + return false; + } + + $limit = $query->getValue(); + return $this->isValidLimit($limit); + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_LIMIT; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Query/Offset.php b/src/Appwrite/Utopia/Database/Validator/Query/Offset.php new file mode 100644 index 000000000..9f832a7c6 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Query/Offset.php @@ -0,0 +1,61 @@ +maxOffset = $maxOffset; + } + + protected function isValidOffset($offset): bool + { + $validator = new Range(0, $this->maxOffset); + if ($validator->isValid($offset)) { + return true; + } + + $this->message = 'Invalid offset: ' . $validator->getDescription(); + return false; + } + + /** + * Is valid. + * + * Returns true if method is offset and values are within range. + * + * @param Query $value + * + * @return bool + */ + public function isValid($query): bool + { + // Validate method + $method = $query->getMethod(); + + if ($method !== Query::TYPE_OFFSET) { + $this->message = 'Query method invalid: ' . $method; + return false; + } + + $offset = $query->getValue(); + return $this->isValidOffset($offset); + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_OFFSET; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Query/Order.php b/src/Appwrite/Utopia/Database/Validator/Query/Order.php new file mode 100644 index 000000000..0c12d7ac4 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Query/Order.php @@ -0,0 +1,68 @@ +schema[$attribute->getAttribute('key')] = $attribute->getArrayCopy(); + } + } + + protected function isValidAttribute($attribute): bool + { + // Search for attribute in schema + if (!isset($this->schema[$attribute])) { + $this->message = 'Attribute not found in schema: ' . $attribute; + return false; + } + + return true; + } + + /** + * Is valid. + * + * Returns true if method is ORDER_ASC or ORDER_DESC and attributes are valid + * + * Otherwise, returns false + * + * @param Query $value + * + * @return bool + */ + public function isValid($query): bool + { + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + if ($method === Query::TYPE_ORDERASC || $method === Query::TYPE_ORDERDESC) { + if ($attribute === '') { + return true; + } + return $this->isValidAttribute($attribute); + } + + return false; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_ORDER; + } +} diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index cc486c965..f2e498366 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Services\Users; use Tests\E2E\Client; -use Utopia\Database\Database; use Utopia\Database\ID; trait UsersBase @@ -997,7 +996,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'queries' => ['offset(1)'] + 'offset' => 1 ]); $this->assertEquals($logs['headers']['status-code'], 200); @@ -1008,16 +1007,80 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'queries' => [ - 'offset(1)', - 'limit(1)', - ] + 'offset' => 1, + 'limit' => 1 ]); $this->assertEquals($logs['headers']['status-code'], 200); $this->assertIsArray($logs['body']['logs']); $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); $this->assertIsNumeric($logs['body']['total']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['limit(-1)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['limit(101)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['offset(-1)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['offset(5001)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("$id", "asdf")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['orderAsc("$id")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); + + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['cursorAsc("$id")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 400); } /** diff --git a/tests/unit/Utopia/Database/Validator/IndexedQueriesTest.php b/tests/unit/Utopia/Database/Validator/IndexedQueriesTest.php new file mode 100644 index 000000000..52375004c --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/IndexedQueriesTest.php @@ -0,0 +1,121 @@ +assertEquals(true, $validator->isValid([])); + } + + public function testInvalidQuery(): void + { + $validator = new IndexedQueries(); + + $this->assertEquals(false, $validator->isValid(["this.is.invalid"])); + } + + public function testInvalidMethod(): void + { + $validator = new IndexedQueries(); + $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + + $validator = new IndexedQueries([], [], new Limit()); + $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + } + + public function testInvalidValue(): void + { + $validator = new IndexedQueries([], [], new Limit()); + $this->assertEquals(false, $validator->isValid(['limit(-1)'])); + } + + public function testValid(): void + { + $attributes = [ + new Document([ + 'key' => 'name', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ]; + $indexes = [ + new Document([ + 'status' => 'available', + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + ]), + new Document([ + 'status' => 'available', + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['name'], + ]), + ]; + $validator = new IndexedQueries( + $attributes, + $indexes, + new Cursor(), + new Filter($attributes), + new Limit(), + new Offset(), + new Order($attributes), + ); + $this->assertEquals(true, $validator->isValid(['cursorAfter("asdf")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("name", "value")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['limit(10)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['offset(10)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['orderAsc("name")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['search("name", "value")']), $validator->getDescription()); + } + + public function testMissingIndex(): void + { + $attributes = [ + new Document([ + 'key' => 'name', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ]; + $indexes = [ + new Document([ + 'status' => 'available', + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + ]), + ]; + $validator = new IndexedQueries( + $attributes, + $indexes, + new Cursor(), + new Filter($attributes), + new Limit(), + new Offset(), + new Order($attributes), + ); + $this->assertEquals(false, $validator->isValid(['equal("dne", "value")']), $validator->getDescription()); + $this->assertEquals(false, $validator->isValid(['orderAsc("dne")']), $validator->getDescription()); + $this->assertEquals(false, $validator->isValid(['search("name", "value")']), $validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Queries/CollectionTest.php b/tests/unit/Utopia/Database/Validator/Queries/CollectionTest.php new file mode 100644 index 000000000..198fc2895 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Queries/CollectionTest.php @@ -0,0 +1,43 @@ +assertEquals($validator->isValid([]), true); + } + + public function testValid(): void + { + $validator = new Collection('users', ['name', 'search']); + $this->assertEquals(true, $validator->isValid(['cursorAfter("asdf")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("name", "value")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['limit(10)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['offset(10)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['orderAsc("name")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['search("search", "value")']), $validator->getDescription()); + } + + public function testMissingIndex(): void + { + $validator = new Collection('users', ['name']); + $this->assertEquals(false, $validator->isValid(['equal("dne", "value")']), $validator->getDescription()); + $this->assertEquals(false, $validator->isValid(['orderAsc("dne")']), $validator->getDescription()); + $this->assertEquals(false, $validator->isValid(['search("search", "value")']), $validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Queries/UsersTest.php b/tests/unit/Utopia/Database/Validator/Queries/UsersTest.php new file mode 100644 index 000000000..24a818c12 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Queries/UsersTest.php @@ -0,0 +1,40 @@ +assertEquals(true, $validator->isValid([]), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("name", "value")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("email", "value")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("phone", "value")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['greaterThan("passwordUpdate", "2020-10-15 06:38")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['greaterThan("registration", "2020-10-15 06:38")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("emailVerification", true)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("phoneVerification", true)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['search("search", "value")']), $validator->getDescription()); + + /** + * Test for Failure + */ + $this->assertEquals(false, $validator->isValid(['equal("password", "value")']), $validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/QueriesTest.php b/tests/unit/Utopia/Database/Validator/QueriesTest.php new file mode 100644 index 000000000..55e04c2b8 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/QueriesTest.php @@ -0,0 +1,76 @@ +assertEquals(true, $validator->isValid([])); + } + + public function testInvalidQuery(): void + { + $validator = new Queries(); + + $this->assertEquals(false, $validator->isValid(["this.is.invalid"])); + } + + public function testInvalidMethod(): void + { + $validator = new Queries(); + $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + + $validator = new Queries(new Limit()); + $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); + } + + public function testInvalidValue(): void + { + $validator = new Queries(new Limit()); + $this->assertEquals(false, $validator->isValid(['limit(-1)'])); + } + + public function testValid(): void + { + $attributes = [ + new Document([ + 'key' => 'name', + 'type' => Database::VAR_STRING, + 'array' => false, + ]) + ]; + $validator = new Queries( + new Cursor(), + new Filter($attributes), + new Limit(), + new Offset(), + new Order($attributes), + ); + $this->assertEquals(true, $validator->isValid(['cursorAfter("asdf")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['equal("name", "value")']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['limit(10)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['offset(10)']), $validator->getDescription()); + $this->assertEquals(true, $validator->isValid(['orderAsc("name")']), $validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Query/CursorTest.php b/tests/unit/Utopia/Database/Validator/Query/CursorTest.php new file mode 100644 index 000000000..0afc8badd --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/CursorTest.php @@ -0,0 +1,41 @@ +validator = new Cursor(); + } + + public function tearDown(): void + { + } + + public function testValue(): void + { + // Test for Success + $this->assertEquals($this->validator->isValid(new Query(Query::TYPE_CURSORAFTER, values: ['asdf'])), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(new Query(Query::TYPE_CURSORBEFORE, values: ['asdf'])), true, $this->validator->getDescription()); + + // Test for Failure + $this->assertEquals($this->validator->isValid(Query::limit(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(101)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(5001)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('attr', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderAsc('attr')), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderDesc('attr')), false, $this->validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Query/FilterTest.php b/tests/unit/Utopia/Database/Validator/Query/FilterTest.php new file mode 100644 index 000000000..8f2f1d44b --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/FilterTest.php @@ -0,0 +1,59 @@ +validator = new Filter( + attributes: [ + new Document([ + 'key' => 'attr', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ], + ); + } + + public function tearDown(): void + { + } + + public function testValue(): void + { + // Test for Success + $this->assertEquals($this->validator->isValid(Query::equal('attr', ['v'])), true, $this->validator->getDescription()); + + // Test for Failure + $this->assertEquals($this->validator->isValid(Query::limit(1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(0)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(100)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(101)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(0)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(5000)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(5001)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('dne', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderAsc('attr')), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderDesc('attr')), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(new Query(Query::TYPE_CURSORAFTER, values: ['asdf'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(new Query(Query::TYPE_CURSORBEFORE, values: ['asdf'])), false, $this->validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Query/LimitTest.php b/tests/unit/Utopia/Database/Validator/Query/LimitTest.php new file mode 100644 index 000000000..e37cb4962 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/LimitTest.php @@ -0,0 +1,37 @@ +validator = new Limit(); + } + + public function tearDown(): void + { + } + + public function testValue(): void + { + // Test for Success + $this->assertEquals($this->validator->isValid(Query::limit(1)), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(0)), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(100)), true, $this->validator->getDescription()); + + // Test for Failure + $this->assertEquals($this->validator->isValid(Query::limit(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(101)), false, $this->validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Query/OffsetTest.php b/tests/unit/Utopia/Database/Validator/Query/OffsetTest.php new file mode 100644 index 000000000..8f7010c71 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/OffsetTest.php @@ -0,0 +1,41 @@ +validator = new Offset(); + } + + public function tearDown(): void + { + } + + public function testValue(): void + { + // Test for Success + $this->assertEquals($this->validator->isValid(Query::offset(1)), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(0)), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(5000)), true, $this->validator->getDescription()); + + // Test for Failure + $this->assertEquals($this->validator->isValid(Query::offset(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(5001)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('attr', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderAsc('attr')), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderDesc('attr')), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(100)), false, $this->validator->getDescription()); + } +} diff --git a/tests/unit/Utopia/Database/Validator/Query/OrderTest.php b/tests/unit/Utopia/Database/Validator/Query/OrderTest.php new file mode 100644 index 000000000..e60b7ce9b --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/OrderTest.php @@ -0,0 +1,54 @@ +validator = new Order( + attributes: [ + new Document([ + 'key' => 'attr', + 'type' => Database::VAR_STRING, + 'array' => false, + ]), + ], + ); + } + + public function tearDown(): void + { + } + + public function testValue(): void + { + // Test for Success + $this->assertEquals($this->validator->isValid(Query::orderAsc('attr')), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderAsc('')), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderDesc('attr')), true, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderDesc('')), true, $this->validator->getDescription()); + + // Test for Failure + $this->assertEquals($this->validator->isValid(Query::limit(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::limit(101)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(-1)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::offset(5001)), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('attr', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('dne', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::equal('', ['v'])), false, $this->validator->getDescription()); + $this->assertEquals($this->validator->isValid(Query::orderDesc('dne')), false, $this->validator->getDescription()); + } +}