diff --git a/src/Appwrite/Platform/Action.php b/src/Appwrite/Platform/Action.php index 5699a67ff2..3db0c74d45 100644 --- a/src/Appwrite/Platform/Action.php +++ b/src/Appwrite/Platform/Action.php @@ -107,9 +107,11 @@ class Action extends UtopiaAction } } - public function disableSubqueries() + public function disableSubqueries(array $filters = []): void { - $filters = $this->filters; + if (empty($filters)) { + $filters = $this->filters; + } foreach ($filters as $filter) { Database::addFilter( @@ -189,6 +191,11 @@ class Action extends UtopiaAction } } + // found a wildcard, return! + if (\in_array('*', $attributes)) { + return; + } + $responseModel = $response->getModel($model); foreach ($responseModel->getRules() as $ruleName => $rule) { if (\str_starts_with($ruleName, '$')) { diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php index 692b467282..32318dd189 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/XList.php @@ -3,19 +3,21 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Extend\Exception; +use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Projects; +use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; +use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Order; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator; use Utopia\Validator\Boolean; @@ -24,6 +26,10 @@ use Utopia\Validator\Text; class XList extends Action { use HTTP; + + // cached mapping of columns to their subQuery filters + private static ?array $attributeToSubQueryFilters = null; + public static function getName() { return 'listProjects'; @@ -61,12 +67,13 @@ class XList extends Action ->param('queries', [], $this->getQueriesValidator(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). 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(', ', Projects::ALLOWED_ATTRIBUTES), true) ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('request') ->inject('response') ->inject('dbForPlatform') ->callback($this->action(...)); } - public function action(array $queries, string $search, bool $includeTotal, Response $response, Database $dbForPlatform) + public function action(array $queries, string $search, bool $includeTotal, Request $request, Response $response, Database $dbForPlatform) { try { $queries = Query::parseQueries($queries); @@ -103,16 +110,89 @@ class XList extends Action $cursor->setValue($cursorDocument); } - $filterQueries = Query::groupByType($queries)['filters']; try { - $projects = $dbForPlatform->find('projects', $queries); + $selectQueries = Query::groupByType($queries)['selections'] ?? []; + $filterQueries = Query::groupByType($queries)['filters']; + + $projects = $this->find($dbForPlatform, $queries, $selectQueries); $total = $includeTotal ? $dbForPlatform->count('projects', $filterQueries, APP_LIMIT_COUNT) : 0; } catch (Order $e) { throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + + $this->applySelectQueries($request, $response, Response::MODEL_PROJECT); $response->dynamic(new Document([ 'projects' => $projects, 'total' => $total, ]), Response::MODEL_PROJECT_LIST); } + + // Build mapping of columns to their subQuery filters + private static function getAttributeToSubQueryFilters(): array + { + if (self::$attributeToSubQueryFilters !== null) { + return self::$attributeToSubQueryFilters; + } + + self::$attributeToSubQueryFilters = []; + + $collections = Config::getParam('collections', []); + $projectAttributes = $collections['platform']['projects']['attributes'] ?? []; + + foreach ($projectAttributes as $attribute) { + $attributeId = $attribute['$id'] ?? null; + $filters = $attribute['filters'] ?? []; + + if ($attributeId === null || empty($filters)) { + continue; + } + + // extract only subQuery filters + $subQueryFilters = \array_filter($filters, function ($filter) { + return \str_starts_with($filter, 'subQuery'); + }); + + if (!empty($subQueryFilters)) { + self::$attributeToSubQueryFilters[$attributeId] = \array_values($subQueryFilters); + } + } + + return self::$attributeToSubQueryFilters; + } + + private function find(Database $dbForPlatform, array $queries, array $selectQueries): array + { + if (empty($selectQueries)) { + return $dbForPlatform->find('projects', $queries); + } + + $selectedAttributes = []; + foreach ($selectQueries as $query) { + foreach ($query->getValues() as $value) { + $selectedAttributes[] = $value; + } + } + + if (\in_array('*', $selectedAttributes)) { + return $dbForPlatform->find('projects', $queries); + } + + $filtersToSkipMap = []; + $selectedAttributesMap = \array_flip($selectedAttributes); + $attributeToSubQueryFilters = self::getAttributeToSubQueryFilters(); + + foreach ($attributeToSubQueryFilters as $attributeName => $subQueryFilters) { + if (!isset($selectedAttributesMap[$attributeName])) { + foreach ($subQueryFilters as $filter) { + $filtersToSkipMap[$filter] = true; + } + } + } + + $filtersToSkip = \array_keys($filtersToSkipMap); + + return empty($filtersToSkip) + ? $dbForPlatform->find('projects', $queries) + : $dbForPlatform->skipFilters(fn () => $dbForPlatform->find('projects', $queries), $filtersToSkip); + } } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php index 5a0befb739..d179703274 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Projects.php @@ -17,4 +17,9 @@ class Projects extends Base { parent::__construct('projects', self::ALLOWED_ATTRIBUTES); } + + public function isSelectQueryAllowed(): bool + { + return true; + } } diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 65f9f7685b..7641e96090 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -341,6 +341,20 @@ class Project extends Model */ public function filter(Document $document): Document { + $this->expandSmtpFields($document); + $this->expandServiceFields($document); + $this->expandAuthFields($document); + $this->expandOAuthProviders($document); + + return $document; + } + + private function expandSmtpFields(Document $document): void + { + if (!$document->isSet('smtp')) { + return; + } + // SMTP $smtp = $document->getAttribute('smtp', []); $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); @@ -352,8 +366,14 @@ class Project extends Model $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); + } + + private function expandServiceFields(Document $document): void + { + if (!$document->isSet('services')) { + return; + } - // Services $values = $document->getAttribute('services', []); $services = Config::getParam('services', []); @@ -365,8 +385,14 @@ class Project extends Model $value = $values[$key] ?? true; $document->setAttribute('serviceStatusFor' . ucfirst($key), $value); } + } + + private function expandAuthFields(Document $document): void + { + if (!$document->isSet('auths')) { + return; + } - // Auth $authValues = $document->getAttribute('auths', []); $auth = Config::getParam('auth', []); @@ -383,13 +409,19 @@ class Project extends Model $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); - foreach ($auth as $index => $method) { + foreach ($auth as $method) { $key = $method['key']; $value = $authValues[$key] ?? true; $document->setAttribute('auth' . ucfirst($key), $value); } + } + + private function expandOAuthProviders(Document $document): void + { + if (!$document->isSet('oAuthProviders')) { + return; + } - // OAuth Providers $providers = Config::getParam('oAuthProviders', []); $providerValues = $document->getAttribute('oAuthProviders', []); $projectProviders = []; @@ -410,7 +442,5 @@ class Project extends Model } $document->setAttribute('oAuthProviders', $projectProviders); - - return $document; } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f2122b4ba9..769d3a4c85 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -436,6 +436,267 @@ class ProjectsConsoleClientTest extends Scope return $data; } + /** + * @group projectsCRUD + */ + public function testListProjectsQuerySelect(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Query Select Test Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Query Select Test Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** + * Test Query.select - basic fields + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'name'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + + $project = $response['body']['projects'][0]; + $this->assertArrayHasKey('$id', $project); + $this->assertArrayHasKey('name', $project); + $this->assertArrayNotHasKey('platforms', $project); + $this->assertArrayNotHasKey('webhooks', $project); + $this->assertArrayNotHasKey('keys', $project); + $this->assertArrayNotHasKey('devKeys', $project); + $this->assertArrayNotHasKey('oAuthProviders', $project); + $this->assertArrayNotHasKey('smtpEnabled', $project); + $this->assertArrayNotHasKey('smtpHost', $project); + $this->assertArrayNotHasKey('authLimit', $project); + $this->assertArrayNotHasKey('authDuration', $project); + + /** + * Test Query.select - multiple fields + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'name', 'teamId', 'description', '$createdAt', '$updatedAt'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + + $project = $response['body']['projects'][0]; + $this->assertArrayHasKey('$id', $project); + $this->assertArrayHasKey('name', $project); + $this->assertArrayHasKey('teamId', $project); + $this->assertArrayHasKey('description', $project); + $this->assertArrayHasKey('$createdAt', $project); + $this->assertArrayHasKey('$updatedAt', $project); + $this->assertArrayNotHasKey('platforms', $project); + $this->assertArrayNotHasKey('webhooks', $project); + $this->assertArrayNotHasKey('keys', $project); + $this->assertArrayNotHasKey('devKeys', $project); + $this->assertArrayNotHasKey('oAuthProviders', $project); + $this->assertArrayNotHasKey('smtpEnabled', $project); + $this->assertArrayNotHasKey('authLimit', $project); + + /** + * Test Query.select combined with filters + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'name', 'teamId'])->toString(), + Query::equal('name', ['Query Select Test Project'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertCount(1, $response['body']['projects']); + + $project = $response['body']['projects'][0]; + $this->assertArrayHasKey('$id', $project); + $this->assertArrayHasKey('name', $project); + $this->assertArrayHasKey('teamId', $project); + $this->assertEquals('Query Select Test Project', $project['name']); + $this->assertEquals($teamId, $project['teamId']); + $this->assertArrayNotHasKey('platforms', $project); + $this->assertArrayNotHasKey('webhooks', $project); + $this->assertArrayNotHasKey('keys', $project); + $this->assertArrayNotHasKey('devKeys', $project); + $this->assertArrayNotHasKey('oAuthProviders', $project); + $this->assertArrayNotHasKey('smtpEnabled', $project); + $this->assertArrayNotHasKey('authLimit', $project); + + /** + * Test Query.select combined with limit + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'name'])->toString(), + Query::limit(2)->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertLessThanOrEqual(2, count($response['body']['projects'])); + + foreach ($response['body']['projects'] as $p) { + $this->assertArrayHasKey('$id', $p); + $this->assertArrayHasKey('name', $p); + $this->assertArrayNotHasKey('platforms', $p); + $this->assertArrayNotHasKey('webhooks', $p); + $this->assertArrayNotHasKey('keys', $p); + $this->assertArrayNotHasKey('devKeys', $p); + $this->assertArrayNotHasKey('oAuthProviders', $p); + $this->assertArrayNotHasKey('smtpEnabled', $p); + $this->assertArrayNotHasKey('authLimit', $p); + } + + /** + * Test Query.select with subquery attributes (platforms, webhooks, etc.) + * When explicitly selected, subqueries should still run + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'name', 'platforms'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + + $project = $response['body']['projects'][0]; + $this->assertArrayHasKey('$id', $project); + $this->assertArrayHasKey('name', $project); + $this->assertArrayHasKey('platforms', $project); + $this->assertIsArray($project['platforms']); + $this->assertArrayNotHasKey('webhooks', $project); + $this->assertArrayNotHasKey('keys', $project); + $this->assertArrayNotHasKey('devKeys', $project); + $this->assertArrayNotHasKey('oAuthProviders', $project); + $this->assertArrayNotHasKey('smtpEnabled', $project); + $this->assertArrayNotHasKey('authLimit', $project); + + /** + * Test Query.select with expanded attributes + * webhooks and keys should load their subquery data when selected + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'name', 'webhooks', 'keys'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + + $project = $response['body']['projects'][0]; + $this->assertArrayHasKey('$id', $project); + $this->assertArrayHasKey('name', $project); + $this->assertArrayHasKey('webhooks', $project); + $this->assertArrayHasKey('keys', $project); + $this->assertIsArray($project['webhooks']); + $this->assertIsArray($project['keys']); + $this->assertArrayNotHasKey('platforms', $project); + $this->assertArrayNotHasKey('devKeys', $project); + $this->assertArrayNotHasKey('smtpEnabled', $project); + $this->assertArrayNotHasKey('authLimit', $project); + + /** + * Test Query.select with wildcard '*' + * Should return all fields like no select query + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['*'])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertGreaterThan(0, count($response['body']['projects'])); + + $project = $response['body']['projects'][0]; + $this->assertArrayHasKey('$id', $project); + $this->assertArrayHasKey('name', $project); + $this->assertArrayHasKey('teamId', $project); + $this->assertArrayHasKey('platforms', $project); + $this->assertArrayHasKey('webhooks', $project); + $this->assertArrayHasKey('keys', $project); + $this->assertArrayHasKey('devKeys', $project); + $this->assertArrayHasKey('oAuthProviders', $project); + $this->assertArrayHasKey('smtpEnabled', $project); + $this->assertArrayHasKey('smtpHost', $project); + $this->assertArrayHasKey('authLimit', $project); + $this->assertArrayHasKey('authDuration', $project); + + /** + * Test Query.select with invalid attribute + */ + $response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['$id', 'invalidAttribute'])->toString(), + ], + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('Invalid `queries` param: Invalid query: Attribute not found in schema: invalidAttribute', $response['body']['message']); + + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + } + public function testGetProject(): void { // Create a team