From cb64484c440a91ad6f8b50c3d96eeb568c5ba9c4 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 8 May 2025 11:59:55 +0530 Subject: [PATCH] update: abstraction over document and row apis. --- .../Databases/Http/Attributes/Action.php | 2 - .../Databases/Http/Documents/Action.php | 138 ++++++++ .../Databases/Http/Documents/Create.php | 318 ++++++++++++++++++ .../Databases/Http/Documents/Delete.php | 174 ++++++++++ .../Modules/Databases/Http/Documents/Get.php | 157 +++++++++ .../Databases/Http/Documents/Logs/XList.php | 164 +++++++++ .../Databases/Http/Documents/Update.php | 305 +++++++++++++++++ .../Databases/Http/Documents/XList.php | 231 +++++++++++++ .../Modules/Databases/Http/Rows/Create.php | 261 +------------- .../Modules/Databases/Http/Rows/Delete.php | 124 ++----- .../Modules/Databases/Http/Rows/Get.php | 114 +------ .../Databases/Http/Rows/Logs/XList.php | 110 +----- .../Modules/Databases/Http/Rows/Update.php | 252 +------------- .../Modules/Databases/Http/Rows/XList.php | 184 +--------- .../Databases/Http/Tables/Logs/XList.php | 5 - 15 files changed, 1575 insertions(+), 964 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/Action.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/Create.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/Get.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/Logs/XList.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/Update.php create mode 100644 src/Appwrite/Platform/Modules/Databases/Http/Documents/XList.php diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Attributes/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Attributes/Action.php index 6d729b8cf3..3cad730b6d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Attributes/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Attributes/Action.php @@ -50,8 +50,6 @@ abstract class Action extends UtopiaAction /** * Get the current context. - * - * @throws \LogicException If context has not been set. */ final protected function getContext(): string { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Action.php new file mode 100644 index 0000000000..d12a9bb8a7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Action.php @@ -0,0 +1,138 @@ +context = $context; + } + + /** + * Get the current context. + */ + final protected function getContext(): string + { + return $this->context; + } + + /** + * Returns true if current context is Collections API. + */ + final protected function isCollectionsAPI(): bool + { + // rows in tables api context + // documents in collections api context + return $this->getContext() === DATABASE_DOCUMENTS_CONTEXT; + } + + /** + * Get the SDK group name for the current action. + * + * Can be used for XList operations as well! + */ + final protected function getSdkGroup(): string + { + return $this->isCollectionsAPI() ? 'documents' : 'rows'; + } + + /** + * Get the correct parent param key (e.g. `tableId` or `collectionId`) + */ + final protected function getParentEventsParamKey(): string + { + return $this->isCollectionsAPI() ? 'collectionId' : 'tableId'; + } + + /** + * Get the correct param key (e.g. `documentId` or `rowId`) + */ + final protected function getEventsParamKey(): string + { + return $this->getContext() . 'Id'; + } + + /** + * Get the appropriate parent level not found exception. + */ + final protected function getParentNotFoundException(): string + { + return $this->isCollectionsAPI() + ? Exception::COLLECTION_NOT_FOUND + : Exception::TABLE_NOT_FOUND; + } + + /** + * Get the appropriate not found exception. + */ + final protected function getNotFoundException(): string + { + return $this->isCollectionsAPI() + ? Exception::DOCUMENT_NOT_FOUND + : Exception::ROW_NOT_FOUND; + } + + /** + * Get the appropriate already exists exception. + */ + final protected function getDuplicateException(): string + { + return $this->isCollectionsAPI() + ? Exception::DOCUMENT_ALREADY_EXISTS + : Exception::ROW_ALREADY_EXISTS; + } + + + /** + * Get the correct invalid structure message. + */ + final protected function getInvalidStructureException(): string + { + return $this->isCollectionsAPI() + ? Exception::DOCUMENT_INVALID_STRUCTURE + : Exception::ROW_INVALID_STRUCTURE; + } + + /** + * Get the appropriate missing data exception. + */ + final protected function getMissingDataException(): string + { + return $this->isCollectionsAPI() + ? Exception::DOCUMENT_MISSING_DATA + : Exception::ROW_MISSING_DATA; + } + + /** + * Get the appropriate missing payload exception. + */ + final protected function getMissingPayloadException(): string + { + return $this->isCollectionsAPI() + ? Exception::DOCUMENT_MISSING_PAYLOAD + : Exception::ROW_MISSING_PAYLOAD; + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Create.php new file mode 100644 index 0000000000..a6e938bc18 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Create.php @@ -0,0 +1,318 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents') + ->desc('Create document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].create') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'row.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'databases', + group: $this->getSdkGroup(), + name: self::getName(), + description: '/docs/references/databases/create-document.md', + auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('documentId', '', new CustomId(), 'Document 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('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.') + ->param('data', [], new JSON(), 'Document data as JSON object.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + { + $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array + + if (empty($data)) { + throw new Exception($this->getMissingDataException()); + } + + if (isset($data['$id'])) { + // `rows` or `documents` in message. + throw new Exception($this->getInvalidStructureException(), '$id is not allowed for creating new ' . $this->getContext() . 's, try update instead'); + } + + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId)); + + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException()); + } + + $allowedPermissions = [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]; + + // Map aggregate permissions to into the set of individual permissions they represent. + $permissions = Permission::aggregate($permissions, $allowedPermissions); + + // Add permissions for current the user if none were provided. + if (\is_null($permissions)) { + $permissions = []; + if (!empty($user->getId())) { + foreach ($allowedPermissions as $permission) { + $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); + } + } + } + + // Users can only manage their own roles, API keys and Admin users can manage any + if (!$isAPIKey && !$isPrivilegedUser) { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); + } + } + } + } + + $data['$collection'] = $collection->getId(); // Adding this param to make API easier for developers + $data['$id'] = $documentId == 'unique()' ? ID::unique() : $documentId; + $data['$permissions'] = $permissions; + $document = new Document($data); + + $operations = 0; + + $checkPermissions = function (Document $collection, Document $document, string $permission) use (&$checkPermissions, $dbForProject, $database, &$operations) { + $operations++; + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $validator = new Authorization($permission); + + $valid = $validator->isValid($collection->getPermissionsByType($permission)); + if (($permission === Database::PERMISSION_UPDATE && !$documentSecurity) || !$valid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + if ($permission === Database::PERMISSION_UPDATE) { + $valid = $valid || $validator->isValid($document->getUpdate()); + if ($documentSecurity && !$valid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + } + + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $document->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + continue; + } + + $isList = \is_array($related) && \array_values($related) === $related; + + if ($isList) { + $relations = $related; + } else { + $relations = [$related]; + } + + $relatedTableId = $relationship->getAttribute('relatedCollection'); + $relatedTable = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) + ); + + foreach ($relations as &$relation) { + if ( + \is_array($relation) + && \array_values($relation) !== $relation + && !isset($relation['$id']) + ) { + $relation['$id'] = ID::unique(); + $relation = new Document($relation); + } + if ($relation instanceof Document) { + $current = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $relatedTable->getInternalId(), $relation->getId()) + ); + + if ($current->isEmpty()) { + $type = Database::PERMISSION_CREATE; + + if (isset($relation['$id']) && $relation['$id'] === 'unique()') { + $relation['$id'] = ID::unique(); + } + } else { + $relation->removeAttribute('$collectionId'); + $relation->removeAttribute('$databaseId'); + $relation->setAttribute('$collection', $relatedTable->getId()); + $type = Database::PERMISSION_UPDATE; + } + + $checkPermissions($relatedTable, $relation, $type); + } + } + + if ($isList) { + $document->setAttribute($relationship->getAttribute('key'), \array_values($relations)); + } else { + $document->setAttribute($relationship->getAttribute('key'), \reset($relations)); + } + } + }; + + $checkPermissions($collection, $document, Database::PERMISSION_CREATE); + + try { + $document = $dbForProject->createDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $document); + } catch (StructureException $e) { + throw new Exception($this->getInvalidStructureException(), $e->getMessage()); + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } catch (NotFoundException) { + throw new Exception($this->getParentNotFoundException()); + } + + + // Add $collectionId and $databaseId for all documents + $processDocument = function (Document $table, Document $document) use (&$processDocument, $dbForProject, $database) { + $document->setAttribute('$databaseId', $database->getId()); + $document->setAttribute('$collectionId', $table->getId()); + + $relationships = \array_filter( + $table->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $document->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + continue; + } + if (!\is_array($related)) { + $related = [$related]; + } + + $relatedCollectionId = $relationship->getAttribute('relatedCollection'); + $relatedCollection = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedCollectionId) + ); + + foreach ($related as $relation) { + if ($relation instanceof Document) { + $processDocument($relatedCollection, $relation); + } + } + } + }; + + $processDocument($collection, $document); + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); // per collection + + $response->addHeader('X-Debug-Operations', $operations); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($document, $this->getResponseModel()); + + $relationships = \array_map( + fn ($row) => $document->getAttribute('key'), + \array_filter( + $collection->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ) + ); + + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setContext('database', $database) + ->setParam($this->getEventsParamKey(), $document->getId()) + ->setPayload($response->getPayload(), sensitive: $relationships) + ->setParam($this->getParentEventsParamKey(), $collection->getId()) + ->setContext($this->isCollectionsAPI() ? 'collection' : 'table', $collection); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Delete.php new file mode 100644 index 0000000000..93a4fdb57e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Delete.php @@ -0,0 +1,174 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Delete document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete') + ->label('audits.event', 'document.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'databases', + group: $this->getSdkGroup(), + name: self::getName(), + description: '/docs/references/databases/delete-document.md', + auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $collectionId, string $documentId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId)); + + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException()); + } + + // Read permission should not be required for delete + $document = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); + + if ($document->isEmpty()) { + throw new Exception($this->getNotFoundException()); + } + + try { + $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $database, $collection, $documentId) { + $dbForProject->deleteDocument( + 'database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), + $documentId + ); + }); + } catch (NotFoundException) { + throw new Exception($this->getParentNotFoundException()); + } + + // Add $collection and $databaseId for all documents + $processDocument = function (Document $collection, Document $document) use (&$processDocument, $dbForProject, $database) { + $document->setAttribute('$databaseId', $database->getId()); + $document->setAttribute('$collectionId', $collection->getId()); + + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $document->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + continue; + } + if (!\is_array($related)) { + $related = [$related]; + } + + $relatedCollectionId = $relationship->getAttribute('relatedCollection'); + $relatedCollection = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedCollectionId) + ); + + foreach ($related as $relation) { + if ($relation instanceof Document) { + $processDocument($relatedCollection, $relation); + } + } + } + }; + + $processDocument($collection, $document); + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); // per collection + + $response->addHeader('X-Debug-Operations', 1); + + $relationships = \array_map( + fn ($document) => $document->getAttribute('key'), + \array_filter( + $collection->getAttribute('attributes', []), + fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP + ) + ); + + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setContext('database', $database) + ->setParam($this->getParentEventsParamKey(), $collection->getId()) + ->setParam($this->getEventsParamKey(), $document->getId()) + ->setContext($this->isCollectionsAPI() ? 'collection' : 'table', $collection) + ->setPayload($response->output($document, $this->getResponseModel()), sensitive: $relationships); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Get.php new file mode 100644 index 0000000000..ddc08f4783 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Get.php @@ -0,0 +1,157 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Get document') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: $this->getSdkGroup(), + name: self::getName(), + description: '/docs/references/databases/get-document.md', + auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForStatsUsage') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId)); + + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException()); + } + + try { + $queries = Query::parseQueries($queries); + $document = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId, $queries); + } catch (AuthorizationException) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + if ($document->isEmpty()) { + throw new Exception($this->getNotFoundException()); + } + + $operations = 0; + + // Add $collectionId and $databaseId for all rows + $processDocument = function (Document $collection, Document $document) use (&$processDocument, $dbForProject, $database, &$operations) { + if ($document->isEmpty()) { + return; + } + + $operations++; + + $document->setAttribute('$databaseId', $database->getId()); + $document->setAttribute('$collectionId', $collection->getId()); + + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $document->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + if (\in_array(\gettype($related), ['array', 'object'])) { + $operations++; + } + + continue; + } + + if (!\is_array($related)) { + $related = [$related]; + } + + $relatedCollectionId = $relationship->getAttribute('relatedCollection'); + $relatedCollection = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedCollectionId) + ); + + foreach ($related as $relation) { + if ($relation instanceof Document) { + $processDocument($relatedCollection, $relation); + } + } + } + }; + + $processDocument($collection, $document); + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); + + $response->addHeader('X-Debug-Operations', $operations); + + $response->dynamic($document, $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Logs/XList.php new file mode 100644 index 0000000000..991c43ca1c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Logs/XList.php @@ -0,0 +1,164 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents/:documentId/logs') + ->desc('List document logs') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: 'logs', + name: self::getName(), + description: '/docs/references/databases/get-document-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document 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 $collectionId, string $documentId, 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); + } + + $collection = $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId); + + if ($collection->isEmpty()) { + throw new Exception($this->getParentNotFoundException()); + } + + $document = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId); + + if ($document->isEmpty()) { + throw new Exception($this->getNotFoundException()); + } + + 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); + // getContext() => `document` or `row`. + $resource = 'database/' . $databaseId . '/collection/' . $collectionId . '/' .$this->getContext(). '/' . $document->getId(); + $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([ + 'logs' => $output, + 'total' => $audit->countLogsByResource($resource, $queries), + ]), $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Update.php new file mode 100644 index 0000000000..e55b9bda39 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/Update.php @@ -0,0 +1,305 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->desc('Update document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'databases', + group: $this->getSdkGroup(), + name: self::getName(), + description: '/docs/references/databases/update-document.md', + auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->inject('queueForStatsUsage') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + { + + $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array + + if (empty($data) && \is_null($permissions)) { + throw new Exception($this->getMissingPayloadException()); + } + + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId)); + + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException()); + } + + // Read permission should not be required for update + /** @var Document $document */ + $document = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); + + if ($document->isEmpty()) { + throw new Exception($this->getNotFoundException()); + } + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions, [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]); + + // Users can only manage their own roles, API keys and Admin users can manage any + $roles = Authorization::getRoles(); + if (!$isAPIKey && !$isPrivilegedUser && !\is_null($permissions)) { + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); + } + } + } + } + + if (\is_null($permissions)) { + $permissions = $document->getPermissions() ?? []; + } + + $data['$id'] = $documentId; + $data['$permissions'] = $permissions; + $newDocument = new Document($data); + + $operations = 0; + + $setCollection = (function (Document $collection, Document $document) use (&$setCollection, $dbForProject, $database, &$operations) { + + $operations++; + + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $document->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + continue; + } + + $isList = \is_array($related) && \array_values($related) === $related; + + if ($isList) { + $relations = $related; + } else { + $relations = [$related]; + } + + $relatedCollectionId = $relationship->getAttribute('relatedCollection'); + $relatedCollection = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedCollectionId) + ); + + foreach ($relations as &$relation) { + // If the relation is an array it can be either update or create a child document. + if ( + \is_array($relation) + && \array_values($relation) !== $relation + && !isset($relation['$id']) + ) { + $relation['$id'] = ID::unique(); + $relation = new Document($relation); + } + if ($relation instanceof Document) { + $oldDocument = Authorization::skip(fn () => $dbForProject->getDocument( + 'database_' . $database->getInternalId() . '_collection_' . $relatedCollection->getInternalId(), + $relation->getId() + )); + $relation->removeAttribute('$collectionId'); + $relation->removeAttribute('$databaseId'); + // Attribute $collection is required for Utopia. + $relation->setAttribute( + '$collection', + 'database_' . $database->getInternalId() . '_collection_' . $relatedCollection->getInternalId() + ); + + if ($oldDocument->isEmpty()) { + if (isset($relation['$id']) && $relation['$id'] === 'unique()') { + $relation['$id'] = ID::unique(); + } + } + $setCollection($relatedCollection, $relation); + } + } + + if ($isList) { + $document->setAttribute($relationship->getAttribute('key'), \array_values($relations)); + } else { + $document->setAttribute($relationship->getAttribute('key'), \reset($relations)); + } + } + }); + + $setCollection($collection, $newDocument); + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); + + $response->addHeader('X-Debug-Operations', $operations); + + try { + $document = $dbForProject->withRequestTimestamp( + $requestTimestamp, + fn () => $dbForProject->updateDocument( + 'database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), + $document->getId(), + $newDocument + ) + ); + } catch (AuthorizationException) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } catch (StructureException $e) { + throw new Exception($this->getInvalidStructureException(), $e->getMessage()); + } catch (NotFoundException) { + throw new Exception($this->getParentNotFoundException()); + } + + // Add $collectionId and $databaseId for all documents + $processDocument = function (Document $table, Document $row) use (&$processDocument, $dbForProject, $database) { + $row->setAttribute('$databaseId', $database->getId()); + $row->setAttribute('$collectionId', $table->getId()); + + $relationships = \array_filter( + $table->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $row->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + continue; + } + if (!\is_array($related)) { + $related = [$related]; + } + + $relatedCollectionId = $relationship->getAttribute('relatedCollection'); + $relatedCollection = Authorization::skip( + fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedCollectionId) + ); + + foreach ($related as $relation) { + if ($relation instanceof Document) { + $processDocument($relatedCollection, $relation); + } + } + } + }; + + $processDocument($collection, $document); + + $response->dynamic($document, $this->getResponseModel()); + + $relationships = \array_map( + fn ($row) => $document->getAttribute('key'), + \array_filter( + $collection->getAttribute('attributes', []), + fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP + ) + ); + + $queueForEvents + ->setParam('databaseId', $databaseId) + ->setContext('database', $database) + ->setParam($this->getEventsParamKey(), $document->getId()) + ->setParam($this->getParentEventsParamKey(), $collection->getId()) + ->setContext($this->isCollectionsAPI() ? 'collection' : 'table', $collection) + ->setPayload($response->getPayload(), sensitive: $relationships); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Documents/XList.php new file mode 100644 index 0000000000..48779ae71d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/Documents/XList.php @@ -0,0 +1,231 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/databases/:databaseId/collections/:collectionId/documents') + ->desc('List documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'databases', + group: $this->getSdkGroup(), + name: self::getName(), + description: '/docs/references/databases/list-documents.md', + auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), '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.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForStatsUsage') + ->callback([$this, 'action']); + } + + public function action(string $databaseId, string $collectionId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage): void + { + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $collectionId)); + + if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { + throw new Exception($this->getParentNotFoundException()); + } + + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + /** + * 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()); + } + + $documentId = $cursor->getValue(); + + $cursorDocument = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $documentId)); + + if ($cursorDocument->isEmpty()) { + $type = ucfirst($this->getContext()); + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "$type '{$documentId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + try { + $documents = $dbForProject->find('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $queries); + $total = $dbForProject->count('database_' . $database->getInternalId() . '_collection_' . $collection->getInternalId(), $queries, APP_LIMIT_COUNT); + } catch (OrderException $e) { + $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; + $attribute = $this->isCollectionsAPI() ? 'attribute' : 'column'; + $message = "The order $attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all $documents order $attribute values are non-null."; + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message); + } + + $operations = 0; + + // Add $collectionId and $databaseId for all documents + $processDocument = (function (Document $collection, Document $document) use (&$processDocument, $dbForProject, $database, &$operations): bool { + if ($document->isEmpty()) { + return false; + } + + $operations++; + + $document->removeAttribute('$collection'); + $document->setAttribute('$databaseId', $database->getId()); + $document->setAttribute('$collectionId', $collection->getId()); + + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn ($attribute) => $attribute->getAttribute('type') === Database::VAR_RELATIONSHIP + ); + + foreach ($relationships as $relationship) { + $related = $document->getAttribute($relationship->getAttribute('key')); + + if (empty($related)) { + if (\in_array(\gettype($related), ['array', 'object'])) { + $operations++; + } + + continue; + } + + if (!\is_array($related)) { + $relations = [$related]; + } else { + $relations = $related; + } + + $relatedCollectionId = $relationship->getAttribute('relatedCollection'); + // todo: Use local cache for this getDocument + $relatedCollection = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedCollectionId)); + + foreach ($relations as $index => $doc) { + if ($doc instanceof Document) { + if (!$processDocument($relatedCollection, $doc)) { + unset($relations[$index]); + } + } + } + + if (\is_array($related)) { + $document->setAttribute($relationship->getAttribute('key'), \array_values($relations)); + } elseif (empty($relations)) { + $document->setAttribute($relationship->getAttribute('key'), null); + } + } + + return true; + }); + + foreach ($documents as $row) { + $processDocument($collection, $row); + } + + $queueForStatsUsage + ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); + + $response->addHeader('X-Debug-Operations', $operations); + + $select = \array_reduce($queries, function ($result, $query) { + return $result || ($query->getMethod() === Query::TYPE_SELECT); + }, false); + + // Check if the SELECT query includes $databaseId and $collectionId + $hasDatabaseId = false; + $hasCollectionId = false; + if ($select) { + $hasDatabaseId = \array_reduce($queries, function ($result, $query) { + return $result || ($query->getMethod() === Query::TYPE_SELECT && \in_array('$databaseId', $query->getValues())); + }, false); + $hasCollectionId = \array_reduce($queries, function ($result, $query) { + return $result || ($query->getMethod() === Query::TYPE_SELECT && \in_array('$collectionId', $query->getValues())); + }, false); + } + + if ($select) { + foreach ($documents as $row) { + if (!$hasDatabaseId) { + $row->removeAttribute('$databaseId'); + } + if (!$hasCollectionId) { + $row->removeAttribute('$collectionId'); + } + } + } + + $response->dynamic(new Document([ + 'total' => $total, + // rows or documents + $this->getSdkGroup() => $documents, + ]), $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Create.php index 87078a5d3d..fa0fca4101 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Create.php @@ -2,10 +2,9 @@ namespace Appwrite\Platform\Modules\Databases\Http\Rows; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; -use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Documents\Create as DocumentCreate; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -14,21 +13,13 @@ use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Exception\NotFound as NotFoundException; -use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; use Utopia\Validator\JSON; -class Create extends Action +class Create extends DocumentCreate { use HTTP; @@ -37,12 +28,18 @@ class Create extends Action return 'createRow'; } + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_ROW; + } + public function __construct() { + $this->setContext(DATABASE_ROWS_CONTEXT); + $this ->setHttpMethod(self::HTTP_REQUEST_METHOD_POST) ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows') - ->httpAlias('/v1/databases/:databaseId/collections/:tableId/documents') ->desc('Create row') ->groups(['api', 'database']) ->label('event', 'databases.[databaseId].tables.[tableId].rows.[rowId].create') @@ -56,14 +53,14 @@ class Create extends Action ->label('sdk', [ new Method( namespace: 'databases', - group: 'rows', - name: 'createRow', + group: $this->getSdkGroup(), + name: self::getName(), description: '/docs/references/databases/create-document.md', auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_CREATED, - model: UtopiaResponse::MODEL_ROW, + model: self::getResponseModel(), ) ], contentType: ContentType::JSON @@ -79,236 +76,8 @@ class Create extends Action ->inject('user') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->callback([$this, 'action']); - } - - public function action(string $databaseId, string $rowId, string $tableId, string|array $data, ?array $permissions, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage): void - { - $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array - - if (empty($data)) { - throw new Exception(Exception::ROW_MISSING_DATA); - } - - if (isset($data['$id'])) { - throw new Exception(Exception::ROW_INVALID_STRUCTURE, '$id is not allowed for creating new rows, try update instead'); - } - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId)); - - if ($table->isEmpty() || (!$table->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - $allowedPermissions = [ - Database::PERMISSION_READ, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ]; - - // Map aggregate permissions to into the set of individual permissions they represent. - $permissions = Permission::aggregate($permissions, $allowedPermissions); - - // Add permissions for current the user if none were provided. - if (\is_null($permissions)) { - $permissions = []; - if (!empty($user->getId())) { - foreach ($allowedPermissions as $permission) { - $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); - } - } - } - - // Users can only manage their own roles, API keys and Admin users can manage any - if (!$isAPIKey && !$isPrivilegedUser) { - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', Authorization::getRoles()) . ')'); - } - } - } - } - - $data['$collection'] = $table->getId(); // Adding this param to make API easier for developers - $data['$id'] = $rowId == 'unique()' ? ID::unique() : $rowId; - $data['$permissions'] = $permissions; - $row = new Document($data); - - $operations = 0; - - $checkPermissions = function (Document $table, Document $row, string $permission) use (&$checkPermissions, $dbForProject, $database, &$operations) { - $operations++; - - $documentSecurity = $table->getAttribute('documentSecurity', false); - $validator = new Authorization($permission); - - $valid = $validator->isValid($table->getPermissionsByType($permission)); - if (($permission === Database::PERMISSION_UPDATE && !$documentSecurity) || !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - - if ($permission === Database::PERMISSION_UPDATE) { - $valid = $valid || $validator->isValid($row->getUpdate()); - if ($documentSecurity && !$valid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - continue; - } - - $isList = \is_array($related) && \array_values($related) === $related; - - if ($isList) { - $relations = $related; - } else { - $relations = [$related]; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - $relatedTable = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) - ); - - foreach ($relations as &$relation) { - if ( - \is_array($relation) - && \array_values($relation) !== $relation - && !isset($relation['$id']) - ) { - $relation['$id'] = ID::unique(); - $relation = new Document($relation); - } - if ($relation instanceof Document) { - $current = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $relatedTable->getInternalId(), $relation->getId()) - ); - - if ($current->isEmpty()) { - $type = Database::PERMISSION_CREATE; - - if (isset($relation['$id']) && $relation['$id'] === 'unique()') { - $relation['$id'] = ID::unique(); - } - } else { - $relation->removeAttribute('$collectionId'); - $relation->removeAttribute('$databaseId'); - $relation->setAttribute('$collection', $relatedTable->getId()); - $type = Database::PERMISSION_UPDATE; - } - - $checkPermissions($relatedTable, $relation, $type); - } - } - - if ($isList) { - $row->setAttribute($relationship->getAttribute('key'), \array_values($relations)); - } else { - $row->setAttribute($relationship->getAttribute('key'), \reset($relations)); - } - } - }; - - $checkPermissions($table, $row, Database::PERMISSION_CREATE); - - try { - $row = $dbForProject->createDocument('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $row); - } catch (StructureException $e) { - throw new Exception(Exception::ROW_INVALID_STRUCTURE, $e->getMessage()); - } catch (DuplicateException) { - throw new Exception(Exception::ROW_ALREADY_EXISTS); - } catch (NotFoundException) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - - // Add $tableId and $databaseId for all rows - $processRow = function (Document $table, Document $row) use (&$processRow, $dbForProject, $database) { - $row->setAttribute('$databaseId', $database->getId()); - $row->setAttribute('$collectionId', $table->getId()); - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - continue; - } - if (!\is_array($related)) { - $related = [$related]; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - $relatedTable = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) - ); - - foreach ($related as $relation) { - if ($relation instanceof Document) { - $processRow($relatedTable, $relation); - } - } - } - }; - - $processRow($table, $row); - - $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); // per collection - - $response->addHeader('X-Debug-Operations', $operations); - - $response - ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) - ->dynamic($row, UtopiaResponse::MODEL_ROW); - - $relationships = \array_map( - fn ($row) => $row->getAttribute('key'), - \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ) - ); - - $queueForEvents - ->setParam('databaseId', $databaseId) - ->setParam('tableId', $table->getId()) - ->setParam('rowId', $row->getId()) - ->setContext('table', $table) - ->setContext('database', $database) - ->setPayload($response->getPayload(), sensitive: $relationships); + ->callback(function (string $databaseId, string $rowId, string $tableId, string|array $data, ?array $permissions, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, StatsUsage $queueForStatsUsage) { + parent::action($databaseId, $rowId, $tableId, $databaseId, $permissions, $response, $dbForProject, $user, $queueForEvents, $queueForStatsUsage); + }); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Delete.php index 61cd4d218a..476e4b83f2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Delete.php @@ -2,25 +2,20 @@ namespace Appwrite\Platform\Modules\Databases\Http\Rows; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; -use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Documents\Delete as DocumentDelete; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\NotFound as NotFoundException; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; -class Delete extends Action +class Delete extends DocumentDelete { use HTTP; @@ -29,12 +24,24 @@ class Delete extends Action return 'deleteRow'; } + /** + * Same explanation as the parent action. + * + * 1. `SDKResponse` uses `UtopiaResponse::MODEL_NONE`. + * 2. But we later need the actual return type for events queue below! + */ + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_ROW; + } + public function __construct() { + $this->setContext(DATABASE_ROWS_CONTEXT); + $this ->setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows/:rowId') - ->httpAlias('/v1/databases/:databaseId/collections/:tableId/documents/:rowId') ->desc('Delete row') ->groups(['api', 'database']) ->label('scope', 'documents.write') @@ -47,8 +54,8 @@ class Delete extends Action ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) ->label('sdk', new Method( namespace: 'databases', - group: 'rows', - name: 'deleteRow', + group: $this->getSdkGroup(), + name: self::getName(), description: '/docs/references/databases/delete-document.md', auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], responses: [ @@ -67,101 +74,8 @@ class Delete extends Action ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->callback([$this, 'action']); - } - - public function action(string $databaseId, string $tableId, string $rowId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void - { - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId)); - - if ($table->isEmpty() || (!$table->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - // Read permission should not be required for delete - $row = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $rowId)); - - if ($row->isEmpty()) { - throw new Exception(Exception::ROW_NOT_FOUND); - } - - try { - $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $database, $table, $rowId) { - $dbForProject->deleteDocument( - 'database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), - $rowId - ); + ->callback(function (string $databaseId, string $tableId, string $rowId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage) { + parent::action($databaseId, $tableId, $rowId, $requestTimestamp, $response, $dbForProject, $queueForEvents, $queueForStatsUsage); }); - } catch (NotFoundException) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - // Add $tableId and $databaseId for all rows - $processRow = function (Document $table, Document $row) use (&$processRow, $dbForProject, $database) { - $row->setAttribute('$databaseId', $database->getId()); - $row->setAttribute('$collectionId', $table->getId()); - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - continue; - } - if (!\is_array($related)) { - $related = [$related]; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - $relatedTable = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) - ); - - foreach ($related as $relation) { - if ($relation instanceof Document) { - $processRow($relatedTable, $relation); - } - } - } - }; - - $processRow($table, $row); - - $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) - ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); // per collection - - $response->addHeader('X-Debug-Operations', 1); - - $relationships = \array_map( - fn ($row) => $row->getAttribute('key'), - \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ) - ); - - $queueForEvents - ->setParam('databaseId', $databaseId) - ->setParam('tableId', $table->getId()) - ->setParam('rowId', $row->getId()) - ->setContext('table', $table) - ->setContext('database', $database) - ->setPayload($response->output($row, UtopiaResponse::MODEL_ROW), sensitive: $relationships); - - $response->noContent(); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Get.php index fb600f5a7f..8eb07ef94e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Get.php @@ -2,28 +2,21 @@ namespace Appwrite\Platform\Modules\Databases\Http\Rows; -use Appwrite\Auth\Auth; use Appwrite\Event\StatsUsage; -use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Documents\Get as DocumentGet; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; -class Get extends Action +class Get extends DocumentGet { use HTTP; @@ -32,8 +25,15 @@ class Get extends Action return 'getRow'; } + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_ROW; + } + public function __construct() { + $this->setContext(DATABASE_ROWS_CONTEXT); + $this ->setHttpMethod(self::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows/:rowId') @@ -44,14 +44,14 @@ class Get extends Action ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: 'databases', - group: 'rows', - name: 'getRow', + group: $this->getSdkGroup(), + name: self::getName(), description: '/docs/references/databases/get-document.md', auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_OK, - model: UtopiaResponse::MODEL_ROW, + model: $this->getResponseModel(), ) ], contentType: ContentType::JSON @@ -63,92 +63,8 @@ class Get extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') - ->callback([$this, 'action']); - } - - public function action(string $databaseId, string $tableId, string $rowId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage): void - { - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId)); - - if ($table->isEmpty() || (!$table->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - $row = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $rowId, $queries); - } catch (AuthorizationException) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - if ($row->isEmpty()) { - throw new Exception(Exception::ROW_NOT_FOUND); - } - - $operations = 0; - - // Add $tableId and $databaseId for all rows - $processRow = function (Document $table, Document $row) use (&$processRow, $dbForProject, $database, &$operations) { - if ($row->isEmpty()) { - return; - } - - $operations++; - - $row->setAttribute('$databaseId', $database->getId()); - $row->setAttribute('$collectionId', $table->getId()); - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - if (\in_array(\gettype($related), ['array', 'object'])) { - $operations++; - } - - continue; - } - - if (!\is_array($related)) { - $related = [$related]; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - $relatedTable = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) - ); - - foreach ($related as $relation) { - if ($relation instanceof Document) { - $processRow($relatedTable, $relation); - } - } - } - }; - - $processRow($table, $row); - - $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); - - $response->addHeader('X-Debug-Operations', $operations); - - $response->dynamic($row, UtopiaResponse::MODEL_ROW); + ->callback(function (string $databaseId, string $tableId, string $rowId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage) { + parent::action($databaseId, $tableId, $rowId, $queries, $response, $dbForProject, $queueForStatsUsage); + }); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Logs/XList.php index 238815d849..ebf5bcca1b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Logs/XList.php @@ -2,31 +2,23 @@ namespace Appwrite\Platform\Modules\Databases\Http\Rows\Logs; -use Appwrite\Detector\Detector; -use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Documents\Logs\XList as DocumentLogXList; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use MaxMind\Db\Reader; -use Utopia\Audit\Audit; use Utopia\Database\Database; -use Utopia\Database\DateTime; -use Utopia\Database\Document; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Queries; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; use Utopia\Locale\Locale; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; -class XList extends Action +class XList extends DocumentLogXList { use HTTP; @@ -37,10 +29,11 @@ class XList extends Action public function __construct() { + $this->setContext(DATABASE_ROWS_CONTEXT); + $this ->setHttpMethod(self::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows/:rowId/logs') - ->httpAlias('/v1/databases/:databaseId/collections/:tableId/documents/:rowId/logs') ->desc('List row logs') ->groups(['api', 'database']) ->label('scope', 'documents.read') @@ -48,13 +41,13 @@ class XList extends Action ->label('sdk', new Method( namespace: 'databases', group: 'logs', - name: 'listRowLogs', + name: self::getName(), description: '/docs/references/databases/get-document-logs.md', auth: [AuthType::ADMIN], responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_OK, - model: UtopiaResponse::MODEL_LOG_LIST, + model: $this->getResponseModel(), ) ], contentType: ContentType::JSON, @@ -67,93 +60,8 @@ class XList extends Action ->inject('dbForProject') ->inject('locale') ->inject('geodb') - ->callback([$this, 'action']); - } - - public function action(string $databaseId, string $tableId, string $rowId, 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); - } - - $table = $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId); - - if ($table->isEmpty()) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - $row = $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $rowId); - - if ($row->isEmpty()) { - throw new Exception(Exception::ROW_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 . '/row/' . $row->getId(); - $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); + ->callback(function (string $databaseId, string $tableId, string $rowId, array $queries, UtopiaResponse $response, Database $dbForProject, Locale $locale, Reader $geodb) { + parent::action($databaseId, $tableId, $rowId, $queries, $response, $dbForProject, $locale, $geodb); + }); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Update.php index 5590aa6c67..d29a8dcd12 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Rows/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Rows/Update.php @@ -2,33 +2,22 @@ namespace Appwrite\Platform\Modules\Databases\Http\Rows; -use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; -use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Documents\Update as DocumentUpdate; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Exception\Duplicate as DuplicateException; -use Utopia\Database\Exception\NotFound as NotFoundException; -use Utopia\Database\Exception\Structure as StructureException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\UID; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; use Utopia\Validator\JSON; -class Update extends Action +class Update extends DocumentUpdate { use HTTP; @@ -37,8 +26,15 @@ class Update extends Action return 'updateRow'; } + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_ROW; + } + public function __construct() { + $this->setContext(DATABASE_ROWS_CONTEXT); + $this ->setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows/:rowId') @@ -55,14 +51,14 @@ class Update extends Action ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) ->label('sdk', new Method( namespace: 'databases', - group: 'rows', - name: 'updateRow', + group: $this->getSdkGroup(), + name: self::getName(), description: '/docs/references/databases/update-document.md', auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_OK, - model: UtopiaResponse::MODEL_ROW, + model: $this->getResponseModel(), ) ], contentType: ContentType::JSON @@ -77,226 +73,8 @@ class Update extends Action ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForStatsUsage') - ->callback([$this, 'action']); - } - - public function action(string $databaseId, string $tableId, string $rowId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void - { - - $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array - - if (empty($data) && \is_null($permissions)) { - throw new Exception(Exception::ROW_MISSING_PAYLOAD); - } - - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId)); - - if ($table->isEmpty() || (!$table->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - // Read permission should not be required for update - /** @var Document $row */ - $row = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $rowId)); - - if ($row->isEmpty()) { - throw new Exception(Exception::ROW_NOT_FOUND); - } - - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions, [ - Database::PERMISSION_READ, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ]); - - // Users can only manage their own roles, API keys and Admin users can manage any - $roles = Authorization::getRoles(); - if (!$isAPIKey && !$isPrivilegedUser && !\is_null($permissions)) { - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); - } - } - } - } - - if (\is_null($permissions)) { - $permissions = $row->getPermissions() ?? []; - } - - $data['$id'] = $rowId; - $data['$permissions'] = $permissions; - $newRow = new Document($data); - - $operations = 0; - - $setTable = (function (Document $table, Document $row) use (&$setTable, $dbForProject, $database, &$operations) { - - $operations++; - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - continue; - } - - $isList = \is_array($related) && \array_values($related) === $related; - - if ($isList) { - $relations = $related; - } else { - $relations = [$related]; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - $relatedTable = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) - ); - - foreach ($relations as &$relation) { - // If the relation is an array it can be either update or create a child document. - if ( - \is_array($relation) - && \array_values($relation) !== $relation - && !isset($relation['$id']) - ) { - $relation['$id'] = ID::unique(); - $relation = new Document($relation); - } - if ($relation instanceof Document) { - $oldRow = Authorization::skip(fn () => $dbForProject->getDocument( - 'database_' . $database->getInternalId() . '_collection_' . $relatedTable->getInternalId(), - $relation->getId() - )); - $relation->removeAttribute('$collectionId'); - $relation->removeAttribute('$databaseId'); - // Attribute $collection is required for Utopia. - $relation->setAttribute( - '$collection', - 'database_' . $database->getInternalId() . '_collection_' . $relatedTable->getInternalId() - ); - - if ($oldRow->isEmpty()) { - if (isset($relation['$id']) && $relation['$id'] === 'unique()') { - $relation['$id'] = ID::unique(); - } - } - $setTable($relatedTable, $relation); - } - } - - if ($isList) { - $row->setAttribute($relationship->getAttribute('key'), \array_values($relations)); - } else { - $row->setAttribute($relationship->getAttribute('key'), \reset($relations)); - } - } - }); - - $setTable($table, $newRow); - - $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); - - $response->addHeader('X-Debug-Operations', $operations); - - try { - $row = $dbForProject->withRequestTimestamp( - $requestTimestamp, - fn () => $dbForProject->updateDocument( - 'database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), - $row->getId(), - $newRow - ) - ); - } catch (AuthorizationException) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } catch (DuplicateException) { - throw new Exception(Exception::ROW_ALREADY_EXISTS); - } catch (StructureException $e) { - throw new Exception(Exception::ROW_INVALID_STRUCTURE, $e->getMessage()); - } catch (NotFoundException) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - // Add $tableId and $databaseId for all rows - $processRow = function (Document $table, Document $row) use (&$processRow, $dbForProject, $database) { - $row->setAttribute('$databaseId', $database->getId()); - $row->setAttribute('$collectionId', $table->getId()); - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - continue; - } - if (!\is_array($related)) { - $related = [$related]; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - $relatedTable = Authorization::skip( - fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId) - ); - - foreach ($related as $relation) { - if ($relation instanceof Document) { - $processRow($relatedTable, $relation); - } - } - } - }; - - $processRow($table, $row); - - $response->dynamic($row, UtopiaResponse::MODEL_ROW); - - $relationships = \array_map( - fn ($row) => $row->getAttribute('key'), - \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ) - ); - - $queueForEvents - ->setParam('databaseId', $databaseId) - ->setParam('tableId', $table->getId()) - ->setParam('rowId', $row->getId()) - ->setContext('table', $table) - ->setContext('database', $database) - ->setPayload($response->getPayload(), sensitive: $relationships); + ->callback(function (string $databaseId, string $tableId, string $rowId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage) { + parent::action($databaseId, $tableId, $rowId, $data, $permissions, $requestTimestamp, $response, $dbForProject, $queueForEvents, $queueForStatsUsage); + }); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Rows/XList.php index 781c0d7c83..a954312493 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Rows/XList.php @@ -2,29 +2,21 @@ namespace Appwrite\Platform\Modules\Databases\Http\Rows; -use Appwrite\Auth\Auth; use Appwrite\Event\StatsUsage; -use Appwrite\Extend\Exception; +use Appwrite\Platform\Modules\Databases\Http\Documents\XList as DocumentXList; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response as UtopiaResponse; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Order as OrderException; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; -use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Swoole\Response as SwooleResponse; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; -class XList extends Action +class XList extends DocumentXList { use HTTP; @@ -33,8 +25,15 @@ class XList extends Action return 'listRows'; } + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_ROW_LIST; + } + public function __construct() { + $this->setContext(DATABASE_ROWS_CONTEXT); + $this ->setHttpMethod(self::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/databases/:databaseId/tables/:tableId/rows') @@ -45,14 +44,14 @@ class XList extends Action ->label('resourceType', RESOURCE_TYPE_DATABASES) ->label('sdk', new Method( namespace: 'databases', - group: 'rows', - name: 'listRows', + group: $this->getSdkGroup(), + name: self::getName(), description: '/docs/references/databases/list-documents.md', auth: [AuthType::SESSION, AuthType::KEY, AuthType::JWT], responses: [ new SDKResponse( code: SwooleResponse::STATUS_CODE_OK, - model: UtopiaResponse::MODEL_ROW_LIST, + model: $this->getResponseModel(), ) ], contentType: ContentType::JSON @@ -63,161 +62,8 @@ class XList extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForStatsUsage') - ->callback([$this, 'action']); - } - - public function action(string $databaseId, string $tableId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage): void - { - $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - - if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::DATABASE_NOT_FOUND); - } - - $table = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $tableId)); - - if ($table->isEmpty() || (!$table->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { - throw new Exception(Exception::TABLE_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - /** - * 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()); - } - - $rowId = $cursor->getValue(); - - $cursorRow = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $rowId)); - - if ($cursorRow->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Row '{$rowId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorRow); - } - try { - $rows = $dbForProject->find('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $queries); - $total = $dbForProject->count('database_' . $database->getInternalId() . '_collection_' . $table->getInternalId(), $queries, APP_LIMIT_COUNT); - } catch (OrderException $e) { - throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order column '{$e->getAttribute()}' had a null value. Cursor pagination requires all rows order column values are non-null."); - } - - $operations = 0; - - // Add $tableId and $databaseId for all rows - $processRow = (function (Document $table, Document $row) use (&$processRow, $dbForProject, $database, &$operations): bool { - if ($row->isEmpty()) { - return false; - } - - $operations++; - - $row->removeAttribute('$collection'); - $row->setAttribute('$databaseId', $database->getId()); - $row->setAttribute('$collectionId', $table->getId()); - - $relationships = \array_filter( - $table->getAttribute('attributes', []), - fn ($column) => $column->getAttribute('type') === Database::VAR_RELATIONSHIP - ); - - foreach ($relationships as $relationship) { - $related = $row->getAttribute($relationship->getAttribute('key')); - - if (empty($related)) { - if (\in_array(\gettype($related), ['array', 'object'])) { - $operations++; - } - - continue; - } - - if (!\is_array($related)) { - $relations = [$related]; - } else { - $relations = $related; - } - - $relatedTableId = $relationship->getAttribute('relatedCollection'); - // todo: Use local cache for this getDocument - $relatedTable = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getInternalId(), $relatedTableId)); - - foreach ($relations as $index => $doc) { - if ($doc instanceof Document) { - if (!$processRow($relatedTable, $doc)) { - unset($relations[$index]); - } - } - } - - if (\is_array($related)) { - $row->setAttribute($relationship->getAttribute('key'), \array_values($relations)); - } elseif (empty($relations)) { - $row->setAttribute($relationship->getAttribute('key'), null); - } - } - - return true; - }); - - foreach ($rows as $row) { - $processRow($table, $row); - } - - $queueForStatsUsage - ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getInternalId(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); - - $response->addHeader('X-Debug-Operations', $operations); - - $select = \array_reduce($queries, function ($result, $query) { - return $result || ($query->getMethod() === Query::TYPE_SELECT); - }, false); - - // Check if the SELECT query includes $databaseId and $collectionId - $hasDatabaseId = false; - $hasTableId = false; - if ($select) { - $hasDatabaseId = \array_reduce($queries, function ($result, $query) { - return $result || ($query->getMethod() === Query::TYPE_SELECT && \in_array('$databaseId', $query->getValues())); - }, false); - $hasTableId = \array_reduce($queries, function ($result, $query) { - return $result || ($query->getMethod() === Query::TYPE_SELECT && \in_array('$collectionId', $query->getValues())); - }, false); - } - - if ($select) { - foreach ($rows as $row) { - if (!$hasDatabaseId) { - $row->removeAttribute('$databaseId'); - } - if (!$hasTableId) { - $row->removeAttribute('$collectionId'); - } - } - } - - $response->dynamic(new Document([ - 'total' => $total, - 'rows' => $rows, - ]), UtopiaResponse::MODEL_ROW_LIST); + ->callback(function (string $databaseId, string $tableId, array $queries, UtopiaResponse $response, Database $dbForProject, StatsUsage $queueForStatsUsage) { + parent::action($databaseId, $tableId, $queries, $response, $dbForProject, $queueForStatsUsage); + }); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php index 9b86dcff8f..90983dc84b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Tables/Logs/XList.php @@ -27,11 +27,6 @@ class XList extends CollectionLogXList return 'listTableLogs'; } - protected function getResponseModel(): string - { - return UtopiaResponse::MODEL_LOG_LIST; - } - public function __construct() { $this->setContext(DATABASE_TABLES_CONTEXT);