Merge pull request #10990 from appwrite/project-queries

Allow queries on projects xlist
This commit is contained in:
Jake Barnby 2025-12-19 14:39:47 +00:00 committed by GitHub
commit 3e96f9c2f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 395 additions and 12 deletions

View file

@ -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, '$')) {

View file

@ -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);
}
}

View file

@ -17,4 +17,9 @@ class Projects extends Base
{
parent::__construct('projects', self::ALLOWED_ATTRIBUTES);
}
public function isSelectQueryAllowed(): bool
{
return true;
}
}

View file

@ -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;
}
}

View file

@ -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