From 8efc1543e9c0889621cdf2ae5011358b464ef8d0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 4 May 2025 17:05:14 +0530 Subject: [PATCH] update: move Table ops to module. --- app/controllers/api/databases.php | 448 ------------------ .../Modules/Databases/Http/Tables/Create.php | 116 +++++ .../Modules/Databases/Http/Tables/Delete.php | 95 ++++ .../Modules/Databases/Http/Tables/Get.php | 74 +++ .../Databases/Http/Tables/Logs/XList.php | 153 ++++++ .../Modules/Databases/Http/Tables/Update.php | 110 +++++ .../Modules/Databases/Http/Tables/XList.php | 117 +++++ 7 files changed, 665 insertions(+), 448 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Tables/Create.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Tables/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Tables/Get.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Tables/Update.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Tables/XList.php diff --git a/app/controllers/api/databases.php b/app/controllers/api/databases.php index dd181f8894..bc80502521 100644 --- a/app/controllers/api/databases.php +++ b/app/controllers/api/databases.php @@ -11,7 +11,6 @@ use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\CustomId; -use Appwrite\Utopia\Database\Validator\Queries\Collections; use Appwrite\Utopia\Database\Validator\Queries\Indexes; use Appwrite\Utopia\Response; use MaxMind\Db\Reader; @@ -23,7 +22,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Query as QueryException; @@ -48,452 +46,6 @@ use Utopia\Validator\JSON; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -App::post('/v1/databases/:databaseId/tables') - ->alias('/v1/databases/:databaseId/collections') - ->desc('Create table') - ->groups(['api', 'database']) - ->label('event', 'databases.[databaseId].tables.[tableId].create') - ->label('scope', 'collections.write') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('audits.event', 'table.create') - ->label('audits.resource', 'database/{request.databaseId}/table/{response.$id}') - ->label('sdk', new Method( - namespace: 'databases', - group: 'tables', - name: 'createTable', - description: '/docs/references/databases/create-collection.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_COLLECTION, - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('tableId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('name', '', new Text(128), 'Table name. Max length: 128 chars.') - ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->inject('queueForEvents') - ->action(function (string $databaseId, string $tableId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, Response $response, Database $dbForProject, string $mode, Event $queueForEvents) { - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $tableId = $tableId == 'unique()' ? ID::unique() : $tableId; - - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions) ?? []; - - try { - $table = $dbForProject->createDocument('database_' . $database->getInternalId(), new Document([ - '$id' => $tableId, - 'databaseInternalId' => $database->getInternalId(), - 'databaseId' => $databaseId, - '$permissions' => $permissions, - 'documentSecurity' => $documentSecurity, - 'enabled' => $enabled, - 'name' => $name, - 'search' => implode(' ', [$tableId, $name]), - ])); - - $dbForProject->createCollection('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), permissions: $permissions, documentSecurity: $documentSecurity); - } catch (DuplicateException) { - throw new Exception(Exception::COLLECTION_ALREADY_EXISTS); - } catch (LimitException) { - throw new Exception(Exception::COLLECTION_LIMIT_EXCEEDED); - } - - $queueForEvents - ->setContext('database', $database) - ->setParam('databaseId', $databaseId) - ->setParam('tableId', $table->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($table, Response::MODEL_COLLECTION); - }); - -App::get('/v1/databases/:databaseId/tables') - ->alias('/v1/databases/:databaseId/collections') - ->desc('List tables') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'databases', - group: 'tables', - name: 'listTables', - description: '/docs/references/databases/list-collections.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_COLLECTION_LIST, - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('queries', [], new Collections(), '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(', ', Collections::ALLOWED_ATTRIBUTES), true) - ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->action(function (string $databaseId, array $queries, string $search, Response $response, Database $dbForProject, string $mode) { - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $queries = Query::parseQueries($queries); - - if (!empty($search)) { - $queries[] = Query::search('search', $search); - } - - /** - * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries - */ - $cursor = \array_filter($queries, function ($query) { - return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); - }); - $cursor = reset($cursor); - if ($cursor) { - /** @var Query $cursor */ - - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $tableId = $cursor->getValue(); - $cursorDocument = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Table '{$tableId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - try { - $tables = $dbForProject->find('database_' . $database->getInternalId(), $queries); - $total = $dbForProject->count('database_' . $database->getInternalId(), $filterQueries, APP_LIMIT_COUNT); - } catch (OrderException $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."); - } - - // TODO: collections > tables - $response->dynamic(new Document([ - 'collections' => $tables, - 'total' => $total, - ]), Response::MODEL_COLLECTION_LIST); - }); - -App::get('/v1/databases/:databaseId/tables/:tableId') - ->alias('/v1/databases/:databaseId/collections/:tableId') - ->desc('Get table') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'databases', - group: 'tables', - name: 'getTable', - description: '/docs/references/databases/get-collection.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_COLLECTION, - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('tableId', '', new UID(), 'Table ID.') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $databaseId, string $tableId, Response $response, Database $dbForProject) { - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); - - if ($table->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $response->dynamic($table, Response::MODEL_COLLECTION); - }); - -App::get('/v1/databases/:databaseId/tables/:tableId/logs') - ->alias('/v1/databases/:databaseId/collections/:tableId/logs') - ->desc('List table logs') - ->groups(['api', 'database']) - ->label('scope', 'collections.read') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('sdk', new Method( - namespace: 'databases', - group: 'tables', - name: 'listTableLogs', - description: '/docs/references/databases/get-collection-logs.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_LOG_LIST, - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('tableId', '', new UID(), 'Table ID.') - ->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/queries). Only supported methods are limit and offset', true) - ->inject('response') - ->inject('dbForProject') - ->inject('locale') - ->inject('geodb') - ->action(function (string $databaseId, string $tableId, array $queries, Response $response, Database $dbForProject, Locale $locale, Reader $geodb) { - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $tableDocument = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); - $table = $dbForProject->getCollection('database_' . $database->getInternalId() . '_collection_' . $tableDocument->getInternalId()); - - if ($table->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - // Temp fix for logs - $queries[] = Query::or([ - Query::greaterThan('$createdAt', DateTime::format(new \DateTime('2025-02-26T01:30+00:00'))), - Query::lessThan('$createdAt', DateTime::format(new \DateTime('2025-02-13T00:00+00:00'))), - ]); - - $audit = new Audit($dbForProject); - $resource = 'database/' . $databaseId . '/table/' . $tableId; - $logs = $audit->getLogsByResource($resource, $queries); - - $output = []; - - foreach ($logs as $i => &$log) { - $log['userAgent'] = (!empty($log['userAgent'])) ? $log['userAgent'] : 'UNKNOWN'; - - $detector = new Detector($log['userAgent']); - $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) - - $os = $detector->getOS(); - $client = $detector->getClient(); - $device = $detector->getDevice(); - - $output[$i] = new Document([ - 'event' => $log['event'], - 'userId' => $log['data']['userId'], - 'userEmail' => $log['data']['userEmail'] ?? null, - 'userName' => $log['data']['userName'] ?? null, - 'mode' => $log['data']['mode'] ?? null, - 'ip' => $log['ip'], - 'time' => $log['time'], - 'osCode' => $os['osCode'], - 'osName' => $os['osName'], - 'osVersion' => $os['osVersion'], - 'clientType' => $client['clientType'], - 'clientCode' => $client['clientCode'], - 'clientName' => $client['clientName'], - 'clientVersion' => $client['clientVersion'], - 'clientEngine' => $client['clientEngine'], - 'clientEngineVersion' => $client['clientEngineVersion'], - 'deviceName' => $device['deviceName'], - 'deviceBrand' => $device['deviceBrand'], - 'deviceModel' => $device['deviceModel'] - ]); - - $record = $geodb->get($log['ip']); - - if ($record) { - $output[$i]['countryCode'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), false) ? \strtolower($record['country']['iso_code']) : '--'; - $output[$i]['countryName'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), $locale->getText('locale.country.unknown')); - } else { - $output[$i]['countryCode'] = '--'; - $output[$i]['countryName'] = $locale->getText('locale.country.unknown'); - } - } - - $response->dynamic(new Document([ - 'total' => $audit->countLogsByResource($resource, $queries), - 'logs' => $output, - ]), Response::MODEL_LOG_LIST); - }); - - -App::put('/v1/databases/:databaseId/tables/:tableId') - ->alias('/v1/databases/:databaseId/collections/:tableId') - ->desc('Update table') - ->groups(['api', 'database', 'schema']) - ->label('scope', 'collections.write') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('event', 'databases.[databaseId].tables.[tableId].update') - ->label('audits.event', 'table.update') - ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') - ->label('sdk', new Method( - namespace: 'databases', - group: 'tables', - name: 'updateTable', - description: '/docs/references/databases/update-collection.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_COLLECTION, - ) - ], - contentType: ContentType::JSON - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('tableId', '', new UID(), 'Table ID.') - ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') - ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) - ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) - ->inject('response') - ->inject('dbForProject') - ->inject('mode') - ->inject('queueForEvents') - ->action(function (string $databaseId, string $tableId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, Response $response, Database $dbForProject, string $mode, Event $queueForEvents) { - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); - - if ($table->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - $permissions ??= $table->getPermissions() ?? []; - - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions); - - $enabled ??= $table->getAttribute('enabled', true); - - $table = $dbForProject->updateDocument( - 'database_' . $database->getInternalId(), - $tableId, - $table - ->setAttribute('name', $name) - ->setAttribute('$permissions', $permissions) - ->setAttribute('documentSecurity', $documentSecurity) - ->setAttribute('enabled', $enabled) - ->setAttribute('search', \implode(' ', [$tableId, $name])) - ); - - $dbForProject->updateCollection('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $permissions, $documentSecurity); - - $queueForEvents - ->setContext('database', $database) - ->setParam('databaseId', $databaseId) - ->setParam('tableId', $table->getId()); - - $response->dynamic($table, Response::MODEL_COLLECTION); - }); - -App::delete('/v1/databases/:databaseId/tables/:tableId') - ->alias('/v1/databases/:databaseId/collections/:tableId') - ->desc('Delete table') - ->groups(['api', 'database', 'schema']) - ->label('scope', 'collections.write') - ->label('resourceType', RESOURCE_TYPE_DATABASES) - ->label('event', 'databases.[databaseId].tables.[tableId].delete') - ->label('audits.event', 'table.delete') - ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') - ->label('sdk', new Method( - namespace: 'databases', - group: 'tables', - name: 'deleteTable', - description: '/docs/references/databases/delete-collection.md', - auth: [AuthType::KEY], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('databaseId', '', new UID(), 'Database ID.') - ->param('tableId', '', new UID(), 'Table ID.') - ->inject('response') - ->inject('dbForProject') - ->inject('queueForDatabase') - ->inject('queueForEvents') - ->inject('mode') - ->action(function (string $databaseId, string $tableId, Response $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, string $mode) { - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - if ($database->isEmpty()) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); - - if ($table->isEmpty()) { - throw new Exception(Exception::COLLECTION_NOT_FOUND); - } - - if (!$dbForProject->deleteDocument('database_' . $database->getInternalId(), $tableId)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove collection from DB'); - } - - $dbForProject->purgeCachedCollection('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId()); - - $queueForDatabase - ->setType(DATABASE_TYPE_DELETE_COLLECTION) - ->setDatabase($database) - ->setTable($table); - - $queueForEvents - ->setContext('database', $database) - ->setParam('databaseId', $databaseId) - ->setParam('tableId', $table->getId()) - ->setPayload($response->output($table, Response::MODEL_COLLECTION)); - - $response->noContent(); - }); - App::post('/v1/databases/:databaseId/tables/:tableId/indexes') ->alias('/v1/databases/:databaseId/collections/:tableId/indexes') ->desc('Create index') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Create.php new file mode 100644 index 0000000000..ead5a64f88 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Create.php @@ -0,0 +1,116 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/databases/:databaseId/tables') + ->httpAlias('/v1/databases/:databaseId/collections') + ->desc('Create table') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].tables.[tableId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'table.create') + ->label('audits.resource', 'database/{request.databaseId}/table/{response.$id}') + ->label('sdk', new Method( + namespace: 'databases', + group: 'tables', + name: 'createTable', + description: '/docs/references/databases/create-collection.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('tableId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Table name. Max length: 128 chars.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $tableId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $tableId = $tableId === 'unique()' ? ID::unique() : $tableId; + + $permissions = Permission::aggregate($permissions) ?? []; + + try { + $table = $dbForProject->createDocument('database_' . $database->getInternalId(), new Document([ + '$id' => $tableId, + 'databaseInternalId' => $database->getInternalId(), + 'databaseId' => $databaseId, + '$permissions' => $permissions, + 'documentSecurity' => $documentSecurity, + 'enabled' => $enabled, + 'name' => $name, + 'search' => \implode(' ', [$tableId, $name]), + ])); + + $dbForProject->createCollection('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), permissions: $permissions, documentSecurity: $documentSecurity); + } catch (DuplicateException) { + throw new Exception(Exception::COLLECTION_ALREADY_EXISTS); + } catch (LimitException) { + throw new Exception(Exception::COLLECTION_LIMIT_EXCEEDED); + } + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam('tableId', $table->getId()); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($table, UtopiaResponse::MODEL_COLLECTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Delete.php new file mode 100644 index 0000000000..b3120ef36d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Delete.php @@ -0,0 +1,95 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/databases/:databaseId/tables/:tableId') + ->httpAlias('/v1/databases/:databaseId/collections/:tableId') + ->desc('Delete table') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].tables.[tableId].delete') + ->label('audits.event', 'table.delete') + ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') + ->label('sdk', new Method( + namespace: 'databases', + group: 'tables', + name: 'deleteTable', + description: '/docs/references/databases/delete-collection.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('tableId', '', new UID(), 'Table ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->callback([$thiss, 'action']); + } + + public function action(string $databaseId, string $tableId, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents): void + { + $database = \Utopia\Database\Validator\Authorization::skip(fn() => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); + if ($table->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('database_' . $database->getInternalId(), $tableId)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove collection from DB'); + } + + $dbForProject->purgeCachedCollection('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId()); + + $queueForDatabase + ->setType(DATABASE_TYPE_DELETE_COLLECTION) + ->setDatabase($database) + ->setTable($table); + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam('tableId', $table->getId()) + ->setPayload($response->output($table, UtopiaResponse::MODEL_COLLECTION)); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Get.php new file mode 100644 index 0000000000..2b0f77fc81 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Get.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/tables/:tableId') + ->httpAlias('/v1/databases/:databaseId/collections/:tableId') + ->desc('Get table') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'tables', + name: 'getTable', + description: '/docs/references/databases/get-collection.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('tableId', '', new UID(), 'Table ID.') + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $tableId, UtopiaResponse $response, Database $dbForProject): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); + + if ($table->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $response->dynamic($table, UtopiaResponse::MODEL_COLLECTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php new file mode 100644 index 0000000000..44a850424f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php @@ -0,0 +1,153 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/logs') + ->httpAlias('/v1/databases/:databaseId/collections/:tableId/logs') + ->desc('List table logs') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'tables', + name: 'listTableLogs', + description: '/docs/references/databases/get-collection-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_LOG_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('tableId', '', new UID(), 'Table ID.') + ->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/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $tableId, array $queries, UtopiaResponse $response, Database $dbForProject, Locale $locale, Reader $geodb): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $tableDocument = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); + $table = $dbForProject->getCollection('database_' . $database->getInternalId() . '_collection_' . $tableDocument->getInternalId()); + + if ($table->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Temp fix for logs + $queries[] = Query::or([ + Query::greaterThan('$createdAt', DateTime::format(new \DateTime('2025-02-26T01:30+00:00'))), + Query::lessThan('$createdAt', DateTime::format(new \DateTime('2025-02-13T00:00+00:00'))), + ]); + + $audit = new Audit($dbForProject); + $resource = 'database/' . $databaseId . '/table/' . $tableId; + $logs = $audit->getLogsByResource($resource, $queries); + + $output = []; + + foreach ($logs as $i => &$log) { + $log['userAgent'] = (!empty($log['userAgent'])) ? $log['userAgent'] : 'UNKNOWN'; + + $detector = new Detector($log['userAgent']); + $detector->skipBotDetection(); // OPTIONAL: If called, bot detection will completely be skipped (bots will be detected as regular devices then) + + $os = $detector->getOS(); + $client = $detector->getClient(); + $device = $detector->getDevice(); + + $output[$i] = new Document([ + 'event' => $log['event'], + 'userId' => $log['data']['userId'], + 'userEmail' => $log['data']['userEmail'] ?? null, + 'userName' => $log['data']['userName'] ?? null, + 'mode' => $log['data']['mode'] ?? null, + 'ip' => $log['ip'], + 'time' => $log['time'], + 'osCode' => $os['osCode'], + 'osName' => $os['osName'], + 'osVersion' => $os['osVersion'], + 'clientType' => $client['clientType'], + 'clientCode' => $client['clientCode'], + 'clientName' => $client['clientName'], + 'clientVersion' => $client['clientVersion'], + 'clientEngine' => $client['clientEngine'], + 'clientEngineVersion' => $client['clientEngineVersion'], + 'deviceName' => $device['deviceName'], + 'deviceBrand' => $device['deviceBrand'], + 'deviceModel' => $device['deviceModel'] + ]); + + $record = $geodb->get($log['ip']); + + if ($record) { + $output[$i]['countryCode'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), false) ? \strtolower($record['country']['iso_code']) : '--'; + $output[$i]['countryName'] = $locale->getText('countries.' . strtolower($record['country']['iso_code']), $locale->getText('locale.country.unknown')); + } else { + $output[$i]['countryCode'] = '--'; + $output[$i]['countryName'] = $locale->getText('locale.country.unknown'); + } + } + + $response->dynamic(new Document([ + 'total' => $audit->countLogsByResource($resource, $queries), + 'logs' => $output, + ]), UtopiaResponse::MODEL_LOG_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Update.php new file mode 100644 index 0000000000..2db6c5d328 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Update.php @@ -0,0 +1,110 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/databases/:databaseId/tables/:tableId') + ->httpAlias('/v1/databases/:databaseId/collections/:tableId') + ->desc('Update table') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].tables.[tableId].update') + ->label('audits.event', 'table.update') + ->label('audits.resource', 'database/{request.databaseId}/table/{request.tableId}') + ->label('sdk', new Method( + namespace: 'databases', + group: 'tables', + name: 'updateTable', + description: '/docs/references/databases/update-collection.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('tableId', '', new UID(), 'Table ID.') + ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $tableId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void + { + $database = Authorization::skip(fn() => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); + if ($table->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $permissions ??= $table->getPermissions() ?? []; + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); + + $enabled ??= $table->getAttribute('enabled', true); + + $table = $dbForProject->updateDocument( + 'database_' . $database->getInternalId(), + $tableId, + $table + ->setAttribute('name', $name) + ->setAttribute('$permissions', $permissions) + ->setAttribute('documentSecurity', $documentSecurity) + ->setAttribute('enabled', $enabled) + ->setAttribute('search', \implode(' ', [$tableId, $name])) + ); + + $dbForProject->updateCollection('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $permissions, $documentSecurity); + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam('tableId', $table->getId()); + + $response->dynamic($table, UtopiaResponse::MODEL_COLLECTION); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/XList.php new file mode 100644 index 0000000000..d75150f2bb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/XList.php @@ -0,0 +1,117 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/tables') + ->httpAlias('/v1/databases/:databaseId/collections') + ->desc('List tables') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'tables', + name: 'listTables', + description: '/docs/references/databases/list-collections.md', + auth: [AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_COLLECTION_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Collections(), '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(', ', Collections::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForProject') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, array $queries, string $search, UtopiaResponse $response, Database $dbForProject): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $queries = Query::parseQueries($queries); + + if (!empty($search)) { + $queries[] = Query::search('search', $search); + } + + /** + * Get cursor document if there was a cursor query, we use array_filter and reset for reference $cursor to $queries + */ + $cursor = \array_filter($queries, function ($query) { + return \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); + }); + $cursor = reset($cursor); + + if ($cursor) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $tableId = $cursor->getValue(); + $cursorDocument = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Table '{$tableId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $tables = $dbForProject->find('database_' . $database->getInternalId(), $queries); + $total = $dbForProject->count('database_' . $database->getInternalId(), $filterQueries, APP_LIMIT_COUNT); + } catch (OrderException $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."); + } + + $response->dynamic(new Document([ + 'collections' => $tables, // TODO: consider renaming to 'tables' + 'total' => $total, + ]), UtopiaResponse::MODEL_COLLECTION_LIST); + } +}