diff --git a/app/config/collections.php b/app/config/collections.php index 5e71a3bf52..6f284000ac 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1297,6 +1297,13 @@ $collections = [ ] ], 'indexes' => [ + [ + '$id' => ID::custom('_key_name'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + 'lengths' => [256], + 'orders' => [Database::ORDER_ASC], + ], [ '$id' => ID::custom('_key_email'), 'type' => Database::INDEX_UNIQUE, @@ -1311,6 +1318,41 @@ $collections = [ 'lengths' => [16], 'orders' => [Database::ORDER_ASC], ], + [ + '$id' => ID::custom('_key_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['status'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_passwordUpdate'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['passwordUpdate'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_registration'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['registration'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_emailVerification'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['emailVerification'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_phoneVerification'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['phoneVerification'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], [ '$id' => ID::custom('_key_search'), 'type' => Database::INDEX_FULLTEXT, diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index 5a125dd480..7df980a78f 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -24,7 +24,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; @@ -37,7 +36,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\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; @@ -1964,15 +1968,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); @@ -1997,14 +2028,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 && !$valid) { $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 63978e5646..a6d4c57f73 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -8,6 +8,10 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Network\Validator\Email; 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; @@ -27,7 +31,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; @@ -340,38 +343,39 @@ App::get('/v1/users') ->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_USER_LIST) + ->param('queries', [], new Users(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Users::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('limit', 25, new Range(0, 100), 'Maximum number of users 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 param to manage pagination. [learn more about pagination](https://appwrite.io/docs/pagination)', true) - ->param('cursor', '', new UID(), 'ID of the user used as the starting point for the query, excluding the user itself. Should be used for efficient pagination when working with large sets of data. [learn more about pagination](https://appwrite.io/docs/pagination)', true) - ->param('cursorDirection', Database::CURSOR_AFTER, new WhiteList([Database::CURSOR_AFTER, Database::CURSOR_BEFORE]), 'Direction of the cursor, can be either \'before\' or \'after\'.', true) - ->param('orderType', Database::ORDER_ASC, new WhiteList([Database::ORDER_ASC, Database::ORDER_DESC], true), 'Order result by ASC or DESC order.', true) ->inject('response') ->inject('dbForProject') - ->action(function (string $search, int $limit, int $offset, string $cursor, string $cursorDirection, string $orderType, Response $response, Database $dbForProject) { + ->action(function (array $queries, string $search, Response $response, Database $dbForProject) { - $filterQueries = []; + $queries = Query::parseQueries($queries); if (!empty($search)) { - $filterQueries[] = Query::search('search', $search); + $queries[] = Query::search('search', $search); } - $queries = []; - $queries[] = Query::limit($limit); - $queries[] = Query::offset($offset); - $queries[] = $orderType === Database::ORDER_ASC ? Query::orderAsc('') : Query::orderDesc(''); - if (!empty($cursor)) { - $cursorDocument = $dbForProject->getDocument('users', $cursor); + // Set default limit + $queries[] = Query::limit(25); + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE)[0] ?? null; + if ($cursor !== null) { + /** @var Query $cursor */ + $userId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('users', $userId); if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "User '{$cursor}' for the 'cursor' value not found."); + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "User '{$userId}' for the 'cursor' value not found."); } - $queries[] = $cursorDirection === Database::CURSOR_AFTER ? Query::cursorAfter($cursorDocument) : Query::cursorBefore($cursorDocument); + $cursor->setValue($cursorDocument); } + $filterQueries = Query::groupByType($queries)['filters']; + $response->dynamic(new Document([ - 'users' => $dbForProject->find('users', \array_merge($filterQueries, $queries)), + 'users' => $dbForProject->find('users', $queries), 'total' => $dbForProject->count('users', $filterQueries, APP_LIMIT_COUNT), ]), Response::MODEL_USER_LIST); }); @@ -525,13 +529,12 @@ 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') - ->action(function (string $userId, int $limit, int $offset, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { + ->action(function (string $userId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { $user = $dbForProject->getDocument('users', $userId); @@ -539,6 +542,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); @@ -621,8 +629,7 @@ App::patch('/v1/users/:userId/status') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('status', (bool) $status)); $events - ->setParam('userId', $user->getId()) - ; + ->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); }); @@ -657,8 +664,7 @@ App::patch('/v1/users/:userId/verification') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', $emailVerification)); $events - ->setParam('userId', $user->getId()) - ; + ->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); }); @@ -693,8 +699,7 @@ App::patch('/v1/users/:userId/verification/phone') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('phoneVerification', $phoneVerification)); $events - ->setParam('userId', $user->getId()) - ; + ->setParam('userId', $user->getId()); $response->dynamic($user, Response::MODEL_USER); }); @@ -813,8 +818,7 @@ App::patch('/v1/users/:userId/email') $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) - ->setAttribute('search', \implode(' ', [$user->getId(), $email, $user->getAttribute('name', ''), $user->getAttribute('phone', '')])) - ; + ->setAttribute('search', \implode(' ', [$user->getId(), $email, $user->getAttribute('name', ''), $user->getAttribute('phone', '')])); try { $user = $dbForProject->updateDocument('users', $user->getId(), $user); @@ -935,8 +939,7 @@ App::patch('/v1/users/:userId/prefs') $user = $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('prefs', $prefs)); $events - ->setParam('userId', $user->getId()) - ; + ->setParam('userId', $user->getId()); $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); }); @@ -978,8 +981,7 @@ App::delete('/v1/users/:userId/sessions/:sessionId') $events ->setParam('userId', $user->getId()) - ->setParam('sessionId', $sessionId) - ; + ->setParam('sessionId', $sessionId); $response->noContent(); }); @@ -1011,7 +1013,8 @@ App::delete('/v1/users/:userId/sessions') $sessions = $user->getAttribute('sessions', []); - foreach ($sessions as $key => $session) { /** @var Document $session */ + foreach ($sessions as $key => $session) { + /** @var Document $session */ $dbForProject->deleteDocument('sessions', $session->getId()); //TODO: fix this } @@ -1020,8 +1023,7 @@ App::delete('/v1/users/:userId/sessions') $events ->setParam('userId', $user->getId()) - ->setPayload($response->output($user, Response::MODEL_USER)) - ; + ->setPayload($response->output($user, Response::MODEL_USER)); $response->noContent(); }); @@ -1059,13 +1061,11 @@ App::delete('/v1/users/:userId') $deletes ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($clone) - ; + ->setDocument($clone); $events ->setParam('userId', $user->getId()) - ->setPayload($response->output($clone, Response::MODEL_USER)) - ; + ->setPayload($response->output($clone, Response::MODEL_USER)); $response->noContent(); }); @@ -1081,7 +1081,7 @@ App::get('/v1/users/usage') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_USAGE_USERS) ->param('range', '30d', new WhiteList(['24h', '7d', '30d', '90d'], true), 'Date range.', true) - ->param('provider', '', new WhiteList(\array_merge(['email', 'anonymous'], \array_map(fn($value) => "oauth-" . $value, \array_keys(Config::getParam('providers', [])))), true), 'Provider Name.', true) + ->param('provider', '', new WhiteList(\array_merge(['email', 'anonymous'], \array_map(fn ($value) => "oauth-" . $value, \array_keys(Config::getParam('providers', [])))), true), 'Provider Name.', true) ->inject('response') ->inject('dbForProject') ->inject('register') diff --git a/composer.lock b/composer.lock index f8325a3773..ebecc59afb 100644 --- a/composer.lock +++ b/composer.lock @@ -481,16 +481,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.4.5", + "version": "7.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "1dd98b0564cb3f6bd16ce683cb755f94c10fbd82" + "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1dd98b0564cb3f6bd16ce683cb755f94c10fbd82", - "reference": "1dd98b0564cb3f6bd16ce683cb755f94c10fbd82", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b50a2a1251152e43f6a37f0fa053e730a67d25ba", + "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba", "shasum": "" }, "require": { @@ -505,10 +505,10 @@ "psr/http-client-implementation": "1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", + "bamarni/composer-bin-plugin": "^1.8.1", "ext-curl": "*", "php-http/client-integration-tests": "^3.0", - "phpunit/phpunit": "^8.5.5 || ^9.3.5", + "phpunit/phpunit": "^8.5.29 || ^9.5.23", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -518,8 +518,12 @@ }, "type": "library", "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, "branch-alias": { - "dev-master": "7.4-dev" + "dev-master": "7.5-dev" } }, "autoload": { @@ -585,7 +589,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.4.5" + "source": "https://github.com/guzzle/guzzle/tree/7.5.0" }, "funding": [ { @@ -601,20 +605,20 @@ "type": "tidelift" } ], - "time": "2022-06-20T22:16:13+00:00" + "time": "2022-08-28T15:39:27+00:00" }, { "name": "guzzlehttp/promises", - "version": "1.5.1", + "version": "1.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da" + "reference": "b94b2807d85443f9719887892882d0329d1e2598" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/fe752aedc9fd8fcca3fe7ad05d419d32998a06da", - "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598", + "reference": "b94b2807d85443f9719887892882d0329d1e2598", "shasum": "" }, "require": { @@ -669,7 +673,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/1.5.1" + "source": "https://github.com/guzzle/promises/tree/1.5.2" }, "funding": [ { @@ -685,20 +689,20 @@ "type": "tidelift" } ], - "time": "2021-10-22T20:56:57+00:00" + "time": "2022-08-28T14:55:35+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "13388f00956b1503577598873fffb5ae994b5737" + "reference": "69568e4293f4fa993f3b0e51c9723e1e17c41379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/13388f00956b1503577598873fffb5ae994b5737", - "reference": "13388f00956b1503577598873fffb5ae994b5737", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/69568e4293f4fa993f3b0e51c9723e1e17c41379", + "reference": "69568e4293f4fa993f3b0e51c9723e1e17c41379", "shasum": "" }, "require": { @@ -712,15 +716,19 @@ "psr/http-message-implementation": "1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4.1", + "bamarni/composer-bin-plugin": "^1.8.1", "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.8 || ^9.3.10" + "phpunit/phpunit": "^8.5.29 || ^9.5.23" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, "branch-alias": { "dev-master": "2.4-dev" } @@ -784,7 +792,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.4.0" + "source": "https://github.com/guzzle/psr7/tree/2.4.1" }, "funding": [ { @@ -800,7 +808,7 @@ "type": "tidelift" } ], - "time": "2022-06-20T21:43:11+00:00" + "time": "2022-08-28T14:45:39+00:00" }, { "name": "influxdb/influxdb-php", @@ -2833,12 +2841,12 @@ "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "1a67d9dcd2884a6a708176955f83e319ac53059e" + "reference": "6e630a62f522ac68a7056bebf81cd032c7a053ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/1a67d9dcd2884a6a708176955f83e319ac53059e", - "reference": "1a67d9dcd2884a6a708176955f83e319ac53059e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6e630a62f522ac68a7056bebf81cd032c7a053ba", + "reference": "6e630a62f522ac68a7056bebf81cd032c7a053ba", "shasum": "" }, "require": { @@ -2876,7 +2884,7 @@ "issues": "https://github.com/appwrite/sdk-generator/issues", "source": "https://github.com/appwrite/sdk-generator/tree/master" }, - "time": "2022-08-19T10:03:22+00:00" + "time": "2022-08-29T10:43:33+00:00" }, { "name": "doctrine/instantiator", @@ -4802,16 +4810,16 @@ }, { "name": "sebastian/type", - "version": "3.0.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "b233b84bc4465aff7b57cf1c4bc75c86d00d6dad" + "reference": "fb44e1cc6e557418387ad815780360057e40753e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b233b84bc4465aff7b57cf1c4bc75c86d00d6dad", - "reference": "b233b84bc4465aff7b57cf1c4bc75c86d00d6dad", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb44e1cc6e557418387ad815780360057e40753e", + "reference": "fb44e1cc6e557418387ad815780360057e40753e", "shasum": "" }, "require": { @@ -4823,7 +4831,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -4846,7 +4854,7 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.0.0" + "source": "https://github.com/sebastianbergmann/type/tree/3.1.0" }, "funding": [ { @@ -4854,7 +4862,7 @@ "type": "github" } ], - "time": "2022-03-15T09:54:48+00:00" + "time": "2022-08-29T06:55:37+00:00" }, { "name": "sebastian/version", diff --git a/src/Appwrite/Utopia/Database/Validator/IndexedQueries.php b/src/Appwrite/Utopia/Database/Validator/IndexedQueries.php new file mode 100644 index 0000000000..1cc0429018 --- /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 8ea70231d5..1e9fe8f208 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries.php @@ -2,28 +2,140 @@ 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 new file mode 100644 index 0000000000..0c29ecd2ff --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Collection.php @@ -0,0 +1,86 @@ + $key, + 'type' => $attribute['type'], + 'array' => $attribute['array'], + ]); + } + + $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([ + 'status' => 'available', + 'type' => Database::INDEX_KEY, + 'attributes' => [$attribute] + ]); + } + $indexes[] = new Document([ + 'status' => 'available', + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'] + ]); + + $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 new file mode 100644 index 0000000000..ffe30f1209 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Users.php @@ -0,0 +1,29 @@ +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; + } + + /** + * 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 0000000000..42bff08a1d --- /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 0000000000..096d036907 --- /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 0000000000..232df93666 --- /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 0000000000..9f832a7c62 --- /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 0000000000..0c12d7ac44 --- /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/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index e5ed8ec0d1..541e8e79f4 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -614,7 +614,7 @@ class AccountCustomClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ - 'search' => $newName + 'search' => $newName, ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -628,7 +628,7 @@ class AccountCustomClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ - 'search' => $id + 'search' => $id, ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -654,7 +654,8 @@ class AccountCustomClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ - 'search' => '"' . $email . '"' + 'search' => '"' . $email . '"', + ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -668,7 +669,7 @@ class AccountCustomClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ - 'search' => $id + 'search' => $id, ]); $this->assertEquals($response['headers']['status-code'], 200); diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 921e7a051d..88bb8872e9 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 @@ -379,11 +378,145 @@ trait UsersBase $this->assertEquals($response['body']['users'][0]['$id'], $data['userId']); $this->assertEquals($response['body']['users'][1]['$id'], 'user1'); + $user1 = $response['body']['users'][1]; + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'cursor' => $response['body']['users'][0]['$id'] + 'queries' => ['equal("name", "' . $user1['name'] . '")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['users']); + $this->assertCount(1, $response['body']['users']); + $this->assertEquals($response['body']['users'][0]['name'], $user1['name']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("email", "' . $user1['email'] . '")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['users']); + $this->assertCount(1, $response['body']['users']); + $this->assertEquals($response['body']['users'][0]['email'], $user1['email']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("status", true)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['users']); + $this->assertCount($totalUsers, $response['body']['users']); + $this->assertEquals($response['body']['users'][0]['$id'], $data['userId']); + $this->assertEquals($response['body']['users'][0]['status'], $user1['status']); + $this->assertEquals($response['body']['users'][1]['$id'], $user1['$id']); + $this->assertEquals($response['body']['users'][1]['status'], $user1['status']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("status", false)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertEmpty($response['body']['users']); + $this->assertCount(0, $response['body']['users']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("passwordUpdate", "' . $user1['passwordUpdate'] . '")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['users']); + $this->assertCount(1, $response['body']['users']); + $this->assertEquals($response['body']['users'][0]['passwordUpdate'], $user1['passwordUpdate']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("registration", "' . $user1['registration'] . '")'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['users']); + $this->assertCount(1, $response['body']['users']); + $this->assertEquals($response['body']['users'][0]['registration'], $user1['registration']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("emailVerification", false)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['users']); + $this->assertCount($totalUsers, $response['body']['users']); + $this->assertEquals($response['body']['users'][0]['$id'], $data['userId']); + $this->assertEquals($response['body']['users'][0]['status'], $user1['status']); + $this->assertEquals($response['body']['users'][1]['$id'], $user1['$id']); + $this->assertEquals($response['body']['users'][1]['status'], $user1['status']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("emailVerification", true)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertEmpty($response['body']['users']); + $this->assertCount(0, $response['body']['users']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("phoneVerification", false)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertIsArray($response['body']['users']); + $this->assertCount($totalUsers, $response['body']['users']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['equal("phoneVerification", true)'] + ]); + + $this->assertEquals($response['headers']['status-code'], 200); + $this->assertNotEmpty($response['body']); + $this->assertEmpty($response['body']['users']); + $this->assertCount(0, $response['body']['users']); + + $response = $this->client->call(Client::METHOD_GET, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => ['cursorAfter("' . $data['userId'] . '")'] ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -396,8 +529,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'cursor' => 'user1', - 'cursorDirection' => Database::CURSOR_BEFORE + 'queries' => ['cursorBefore("user1")'] ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -413,7 +545,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => 'Ronaldo' + 'search' => "Ronaldo", ]); $this->assertEquals($response['headers']['status-code'], 200); $this->assertNotEmpty($response['body']); @@ -425,7 +557,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => 'cristiano.ronaldo@manchester-united.co.uk' + 'search' => "cristiano.ronaldo@manchester-united.co.uk", ]); $this->assertEquals($response['headers']['status-code'], 200); $this->assertNotEmpty($response['body']); @@ -437,7 +569,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => 'cristiano.ronaldo' + 'search' => "cristiano.ronaldo", ]); $this->assertEquals($response['headers']['status-code'], 200); $this->assertNotEmpty($response['body']); @@ -449,7 +581,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => 'manchester' + 'search' => "manchester", ]); $this->assertEquals($response['headers']['status-code'], 200); $this->assertNotEmpty($response['body']); @@ -461,7 +593,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => 'united.co.uk' + 'search' => "united.co.uk", ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -475,7 +607,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => 'man' + 'search' => "man", ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -489,7 +621,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => $data['userId'] + 'search' => $data['userId'], ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -505,7 +637,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'cursor' => 'unknown' + 'queries' => ['cursorAfter("unknown")'] ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -596,7 +728,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => $newName + 'search' => $newName, ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -609,7 +741,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => $id + 'search' => $id, ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -663,7 +795,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => $newEmail + 'search' => $newEmail, ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -676,7 +808,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'search' => $id + 'search' => $id, ]); $this->assertEquals($response['headers']['status-code'], 200); @@ -852,7 +984,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'limit' => 1 + 'queries' => [ 'limit(1)' ], ]); $this->assertEquals($logs['headers']['status-code'], 200); @@ -864,7 +996,7 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'offset' => 1 + 'queries' => [ 'offset(1)' ], ]); $this->assertEquals($logs['headers']['status-code'], 200); @@ -875,14 +1007,79 @@ trait UsersBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ - 'offset' => 1, - 'limit' => 1 + 'queries' => [ 'limit(1)', 'offset(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 0000000000..52375004ca --- /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 0000000000..198fc2895d --- /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 0000000000..24a818c128 --- /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 0000000000..55e04c2b84 --- /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 0000000000..0afc8baddd --- /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 0000000000..8f2f1d44ba --- /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 0000000000..1594d0db1f --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/LimitTest.php @@ -0,0 +1,37 @@ +validator = new Limit(100); + } + + 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 0000000000..4a29117e83 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/OffsetTest.php @@ -0,0 +1,41 @@ +validator = new Offset(5000); + } + + 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 0000000000..fe1b42d5c1 --- /dev/null +++ b/tests/unit/Utopia/Database/Validator/Query/OrderTest.php @@ -0,0 +1,55 @@ +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()); + $this->assertEquals($this->validator->isValid(Query::orderAsc('dne')), false, $this->validator->getDescription()); + } +}